yeti_types/plugins/oauth.rs
1//! OAuth callback claim mutation.
2//!
3//! Replaces the legacy `OAuthClaimsProcessor` trait in `yeti-auth`.
4//! Plugins that want to enrich the User row from OAuth provider
5//! claims (an Okta plugin reading `groups[0]` and writing it to
6//! `roleId`, an Azure AD plugin building a permissions object,
7//! etc.) register a
8//! `tower::Service<OAuthRequest, Response = OAuthResponse>`.
9//!
10//! The host runs the registered pipeline on every OAuth callback
11//! before the User row is persisted. Each plugin receives the
12//! current User JSON (mutated by prior plugins) and returns the
13//! next-stage version. Plugins that don't care about a specific
14//! provider should fast-path return the input unchanged.
15
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17use serde_json::Value;
18use tower::util::BoxCloneSyncService;
19
20use crate::error::YetiError;
21
22/// JSON-as-string wire helper. See [`super::mcp::value_as_json_string`]
23/// for the rationale — bincode 1.3 cannot round-trip `serde_json::Value`
24/// (`Value::deserialize` uses `deserialize_any`, which bincode rejects).
25/// The hook bridge uses bincode, so every `Value` field that
26/// crosses the WIT boundary must be encoded as a JSON string.
27mod value_as_json_string {
28 use super::{Deserialize, Deserializer, Serializer, Value};
29
30 pub(super) fn serialize<S>(value: &Value, serializer: S) -> Result<S::Ok, S::Error>
31 where
32 S: Serializer,
33 {
34 let s = serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
35 serializer.serialize_str(&s)
36 }
37
38 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Value, D::Error>
39 where
40 D: Deserializer<'de>,
41 {
42 let s = String::deserialize(deserializer)?;
43 serde_json::from_str(&s).map_err(serde::de::Error::custom)
44 }
45}
46
47/// Versioned hook chain name for OAuth claims-mutation services
48/// (ADR-009).
49///
50/// Lives here as the canonical source-of-truth constant for any
51/// future cross-component hook bridge. The in-process surface is
52/// `yeti_auth::auth_hooks::AuthHooks::register_oauth_service`.
53///
54/// When the wire shape of [`OAuthRequest`] / [`OAuthResponse`] changes
55/// incompatibly, ship a `v2` alongside, have yeti-auth's dispatcher
56/// read both, and let v1 plugins age out.
57pub const OAUTH_HOOK_CHAIN_NAME: &str = "yeti.auth.oauth.v1";
58
59/// Tower-shaped OAuth-callback claims-mutation service. Plugins
60/// register `BoxCloneSyncService::new(service_fn(...))` against this
61/// type; yeti-auth chains every registered service per OAuth callback,
62/// threading the (possibly mutated) `User` JSON through each in turn.
63pub type OAuthService = BoxCloneSyncService<OAuthRequest, OAuthResponse, YetiError>;
64
65/// Input to the OAuth claims pipeline.
66///
67/// `Serialize` + `Deserialize` derives carry the wire shape
68/// (cross-component hook chains via WIT, bincode payload). The two
69/// `Value` fields (`profile`, `user`) ride the wire as JSON-encoded strings to
70/// sidestep bincode's `DeserializeAnyNotSupported`; the Rust API still
71/// holds them as `Value` for ergonomic in-process use.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OAuthRequest {
74 /// Provider name as registered in `[package.metadata.auth.oauth]`
75 /// (e.g. `"google"`, `"github"`, `"okta"`).
76 pub provider: String,
77 /// App id the user is authenticating against. Empty string for
78 /// global / no-app authentication.
79 pub app_id: String,
80 /// Email resolved from the provider response (lowercased).
81 pub email: String,
82 /// Raw provider profile JSON (claims, profile, etc.).
83 #[serde(with = "value_as_json_string")]
84 pub profile: Value,
85 /// In-flight User row — plugins mutate this in place via the
86 /// pipeline. Yeti's canonical fields (`username`, `email`,
87 /// `passwordHash`, `active`, `createdAt`, `updatedAt`) are
88 /// populated by the host before the pipeline runs.
89 #[serde(with = "value_as_json_string")]
90 pub user: Value,
91}
92
93/// Output of the OAuth claims pipeline — the mutated User row,
94/// ready for persistence.
95///
96/// Wrapped in a `#[serde(transparent)]` newtype so the `Value` payload
97/// can ride the bincode wire as a JSON-encoded string while
98/// the Rust API stays close to the underlying `serde_json::Value`.
99/// In-process callers construct with `OAuthResponse(value)` and unwrap
100/// via `response.0` or destructuring.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(transparent)]
103pub struct OAuthResponse(#[serde(with = "value_as_json_string")] pub Value);
104
105impl From<Value> for OAuthResponse {
106 fn from(value: Value) -> Self {
107 Self(value)
108 }
109}
110
111impl OAuthResponse {
112 /// Unwrap to the inner `Value`.
113 #[must_use]
114 pub fn into_inner(self) -> Value {
115 self.0
116 }
117}