diff --git a/Cargo.lock b/Cargo.lock index 056aeb1..5d6026c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2299,6 +2299,7 @@ dependencies = [ "serde", "serde_json", "strum", + "tokio", "totp-rs", "tower-sessions", "tracing", diff --git a/auth/client/rs/src/api/login.rs b/auth/client/rs/src/api/login.rs index e5bd7d6..63de41d 100644 --- a/auth/client/rs/src/api/login.rs +++ b/auth/client/rs/src/api/login.rs @@ -148,6 +148,58 @@ pub type ExchangeForJwtResponse = JwtResponse; // +/// The OAuth 2.0 subject token type (RFC 8693). +/// Identifies the security token presented for exchange. +#[typeshare] +#[derive( + Debug, Clone, Serialize, Deserialize, Display, EnumString, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub enum SubjectTokenType { + /// OIDC ID Token (`urn:ietf:params:oauth:token-type:id_token`) + OidcIdToken, + /// Google ID Token (`urn:ietf:params:oauth:token-type:id_token`, Google issuer) + GoogleIdToken, + /// GitHub Access Token (`urn:ietf:params:oauth:token-type:access_token`) + GitHubAccessToken, +} + +#[allow(unused)] +#[cfg(feature = "utoipa")] +#[utoipa::path( + post, + path = "/login/ExchangeProviderTokenForJwt", + description = "Exchange an OAuth provider token (OIDC ID Token, Google ID Token, or GitHub Access Token) for a Komodo JWT. Follows RFC 8693 token exchange semantics.", + request_body(content = ExchangeProviderTokenForJwt), + responses( + (status = 200, description = "Authentication JWT", body = ExchangeProviderTokenForJwtResponse), + (status = 401, description = "Unauthorized — token invalid or provider not configured", body = mogh_error::Serror), + (status = 500, description = "Request failed", body = mogh_error::Serror) + ), +)] +fn exchange_provider_token_for_jwt() {} + +/// Exchange an OAuth provider token for a JWT (RFC 8693 Token Exchange). +/// Response: [ExchangeProviderTokenForJwtResponse]. +#[typeshare] +#[derive(Serialize, Deserialize, Debug, Clone, Resolve)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[empty_traits(MoghAuthLoginRequest)] +#[response(ExchangeProviderTokenForJwtResponse)] +#[error(mogh_error::Error)] +pub struct ExchangeProviderTokenForJwt { + /// The type of the presented token (RFC 8693 `subject_token_type`). + pub subject_token_type: SubjectTokenType, + /// The token to exchange (RFC 8693 `subject_token`). + pub subject_token: String, +} + +/// Response for [ExchangeProviderTokenForJwt]. +#[typeshare] +pub type ExchangeProviderTokenForJwtResponse = JwtResponse; + +// + #[allow(unused)] #[cfg(feature = "utoipa")] #[utoipa::path( diff --git a/auth/client/rs/src/openapi.rs b/auth/client/rs/src/openapi.rs index 3bb106c..cbb6c54 100644 --- a/auth/client/rs/src/openapi.rs +++ b/auth/client/rs/src/openapi.rs @@ -13,6 +13,7 @@ mod auth { // ========= auth::get_login_options, auth::exchange_for_jwt, + auth::exchange_provider_token_for_jwt, auth::complete_passkey_login, auth::complete_totp_login, // ========== diff --git a/auth/client/ts/src/responses.ts b/auth/client/ts/src/responses.ts index eb2b261..a33082c 100644 --- a/auth/client/ts/src/responses.ts +++ b/auth/client/ts/src/responses.ts @@ -5,6 +5,7 @@ export type LoginResponses = { SignUpLocalUser: Types.SignUpLocalUserResponse; LoginLocalUser: Types.LoginLocalUserResponse; ExchangeForJwt: Types.ExchangeForJwtResponse; + ExchangeProviderTokenForJwt: Types.ExchangeProviderTokenForJwtResponse; CompleteTotpLogin: Types.CompleteTotpLoginResponse; CompletePasskeyLogin: Types.CompletePasskeyLoginResponse; }; diff --git a/auth/client/ts/src/types.ts b/auth/client/ts/src/types.ts index 7d13652..223e976 100644 --- a/auth/client/ts/src/types.ts +++ b/auth/client/ts/src/types.ts @@ -37,6 +37,9 @@ export type DeleteApiKeyV2Response = NoData; /** Response for [ExchangeForJwt]. */ export type ExchangeForJwtResponse = JwtResponse; +/** Response for [ExchangeProviderTokenForJwt]. */ +export type ExchangeProviderTokenForJwtResponse = JwtResponse; + export type JsonValue = any; /** JSON containing either an authentication token or the required 2fa auth check. */ @@ -248,6 +251,27 @@ export interface DeleteApiKeyV2 { export interface ExchangeForJwt { } +/** The OAuth 2.0 subject token type (RFC 8693). */ +export enum SubjectTokenType { + /** OIDC ID Token (`urn:ietf:params:oauth:token-type:id_token`) */ + OidcIdToken = "OidcIdToken", + /** Google ID Token (`urn:ietf:params:oauth:token-type:id_token`, Google issuer) */ + GoogleIdToken = "GoogleIdToken", + /** GitHub Access Token (`urn:ietf:params:oauth:token-type:access_token`) */ + GitHubAccessToken = "GitHubAccessToken", +} + +/** + * Exchange an OAuth provider token for a JWT (RFC 8693 Token Exchange). + * Response: [ExchangeProviderTokenForJwtResponse]. + */ +export interface ExchangeProviderTokenForJwt { + /** The type of the presented token (RFC 8693 `subject_token_type`). */ + subject_token_type: SubjectTokenType; + /** The token to exchange (RFC 8693 `subject_token`). */ + subject_token: string; +} + /** * Get the available options to login, eg. local and external providers. * Response: [GetLoginOptionsResponse]. @@ -360,6 +384,7 @@ export enum ExternalLoginProvider { export type LoginRequest = | { type: "GetLoginOptions", params: GetLoginOptions } | { type: "ExchangeForJwt", params: ExchangeForJwt } + | { type: "ExchangeProviderTokenForJwt", params: ExchangeProviderTokenForJwt } | { type: "SignUpLocalUser", params: SignUpLocalUser } | { type: "LoginLocalUser", params: LoginLocalUser } | { type: "CompletePasskeyLogin", params: CompletePasskeyLogin } diff --git a/auth/server/Cargo.toml b/auth/server/Cargo.toml index 40cdde8..2f86528 100644 --- a/auth/server/Cargo.toml +++ b/auth/server/Cargo.toml @@ -36,4 +36,7 @@ serde.workspace = true strum.workspace = true rand.workspace = true axum.workspace = true -uuid.workspace = true \ No newline at end of file +uuid.workspace = true + +[dev-dependencies] +tokio.workspace = true \ No newline at end of file diff --git a/auth/server/src/api/login/mod.rs b/auth/server/src/api/login/mod.rs index a96433f..9b60c00 100644 --- a/auth/server/src/api/login/mod.rs +++ b/auth/server/src/api/login/mod.rs @@ -38,6 +38,7 @@ pub struct LoginArgs { pub enum LoginRequest { GetLoginOptions(GetLoginOptions), ExchangeForJwt(ExchangeForJwt), + ExchangeProviderTokenForJwt(ExchangeProviderTokenForJwt), SignUpLocalUser(SignUpLocalUser), LoginLocalUser(LoginLocalUser), CompletePasskeyLogin(CompletePasskeyLogin), @@ -133,26 +134,93 @@ impl Resolve for GetLoginOptions { } impl Resolve for ExchangeForJwt { - #[instrument("ExchangeForJwt", skip_all, fields(ip = ip.to_string()))] async fn resolve( self, LoginArgs { auth, session, ip }: &LoginArgs, ) -> Result { - async { - let user_id = session.retrieve_authenticated_user_id().await?; - auth.jwt_provider().encode_sub(&user_id).map_err(Into::into) - } - .with_failure_rate_limit_using_ip(auth.general_rate_limiter(), ip) - .await + exchange_for_jwt(auth, session, ip).await + } +} + +impl Resolve for ExchangeProviderTokenForJwt { + async fn resolve( + self, + LoginArgs { auth, ip, ..}: &LoginArgs, + ) -> Result { + exchange_provider_token_for_jwt(auth, ip, self).await + } +} + +#[instrument("ExchangeForJwt", skip_all, fields(ip = ip.to_string()))] +async fn exchange_for_jwt( + auth: &BoxAuthImpl, + session: &Session, + ip: &IpAddr, +) -> Result { + async { + let user_id = session.retrieve_authenticated_user_id().await?; + auth.jwt_provider().encode_sub(&user_id).map_err(Into::into) } + .with_failure_rate_limit_using_ip(auth.general_rate_limiter(), ip) + .await +} + +#[instrument( + "ExchangeProviderTokenForJwt", + skip_all, + fields(ip = ip.to_string(), subject_token_type = %request.subject_token_type) +)] +async fn exchange_provider_token_for_jwt( + auth: &BoxAuthImpl, + ip: &IpAddr, + request: ExchangeProviderTokenForJwt, +) -> Result { + async { + let user_id = match request.subject_token_type { + SubjectTokenType::OidcIdToken => { + auth + .exchange_and_validate_oidc_token(&request.subject_token) + .await? + } + SubjectTokenType::GitHubAccessToken => { + auth + .exchange_and_validate_github_token(&request.subject_token) + .await? + } + SubjectTokenType::GoogleIdToken => { + auth + .exchange_and_validate_google_token(&request.subject_token) + .await? + } + }; + + auth.jwt_provider().encode_sub(&user_id).map_err(Into::into) + } + .with_failure_rate_limit_using_ip(auth.general_rate_limiter(), ip) + .await } #[cfg(test)] mod tests { + use std::net::IpAddr; + use std::sync::LazyLock; use super::*; use crate::AuthImpl; + use crate::provider::jwt::JwtProvider; use mogh_auth_client::config::OidcConfig; + static SHARED_JWT_PROVIDER: LazyLock = + LazyLock::new( + || JwtProvider::new( + b"test-secret-login-mod", + 60_000 + ) + ); + + fn loopback() -> IpAddr { + IpAddr::from([127, 0, 0, 1]) + } + /// Minimal AuthImpl for testing struct TestAuth { local: bool, @@ -225,9 +293,137 @@ mod tests { }) } - fn jwt_provider(&self) -> &crate::provider::jwt::JwtProvider { - panic!("not needed for these tests") + fn jwt_provider(&self) -> &JwtProvider { + &SHARED_JWT_PROVIDER + } + } + + // ── ExchangeTestAuth — for ExchangeProviderTokenForJwt tests ───────────────── + // + // Token contracts (any other value → error): + // "oidc_ok" → subject "oidc-subject" (OidcIdToken) + // "github_ok" → subject "github-subject" (GitHubAccessToken) + // "google_ok" → subject "google-subject" (GoogleIdToken) + + struct ExchangeTestAuth; + + impl AuthImpl for ExchangeTestAuth { + fn new() -> Self { + Self + } + + fn get_user( + &self, + _user_id: String, + ) -> crate::DynFuture> + { + Box::pin(async { Err(anyhow::anyhow!("not implemented").into()) }) + } + + fn handle_request_authentication( + &self, + _auth: crate::RequestAuthentication, + _require_user_enabled: bool, + _req: axum::extract::Request, + ) -> crate::DynFuture> + { + Box::pin(async { + Err(anyhow::anyhow!("not implemented").into()) + }) + } + + fn jwt_provider(&self) -> &JwtProvider { + &SHARED_JWT_PROVIDER + } + + fn exchange_and_validate_oidc_token( + &self, + token: &str, + ) -> crate::DynFuture> { + let token = token.to_owned(); + Box::pin(async move { + if token == "oidc_ok" { + Ok("oidc-subject".to_string()) + } else { + Err(anyhow::anyhow!("invalid oidc token").into()) + } + }) + } + + fn exchange_and_validate_github_token( + &self, + token: &str, + ) -> crate::DynFuture> { + let token = token.to_owned(); + Box::pin(async move { + if token == "github_ok" { + Ok("github-subject".to_string()) + } else { + Err(anyhow::anyhow!("invalid github token").into()) + } + }) } + + fn exchange_and_validate_google_token( + &self, + token: &str, + ) -> crate::DynFuture> { + let token = token.to_owned(); + Box::pin(async move { + if token == "google_ok" { + Ok("google-subject".to_string()) + } else { + Err(anyhow::anyhow!("invalid google token").into()) + } + }) + } + } + + fn exchange_auth() -> crate::BoxAuthImpl { + Box::new(ExchangeTestAuth) + } + + fn exchange_request( + token_type: SubjectTokenType, + token: &str, + ) -> ExchangeProviderTokenForJwt { + ExchangeProviderTokenForJwt { + subject_token_type: token_type, + subject_token: token.to_string(), + } + } + + async fn assert_exchange_subject( + token_type: SubjectTokenType, + token: &str, + expected_subject: &str, + ) { + let response = + exchange_provider_token_for_jwt( + &exchange_auth(), + &loopback(), + exchange_request(token_type, token) + ) + .await + .expect("exchange should succeed"); + let subject = SHARED_JWT_PROVIDER + .decode_sub(&response.jwt) + .expect("jwt should decode"); + assert_eq!(subject, expected_subject); + } + + async fn assert_exchange_fails(token_type: SubjectTokenType, token: &str) { + let result = + exchange_provider_token_for_jwt( + &exchange_auth(), + &loopback(), + exchange_request(token_type, token) + ) + .await; + assert!( + result.is_err(), + "exchange should have failed for token {token:?} but succeeded" + ); } #[test] @@ -376,4 +572,87 @@ mod tests { let opts = get_login_options(&auth); assert!(!opts.oidc_auto_redirect); } + + #[tokio::test] + async fn test_exchange_oidc_mints_jwt_for_correct_subject() { + assert_exchange_subject( + SubjectTokenType::OidcIdToken, + "oidc_ok", + "oidc-subject" + ).await; + } + + #[tokio::test] + async fn test_exchange_github_mints_jwt_for_correct_subject() { + assert_exchange_subject( + SubjectTokenType::GitHubAccessToken, + "github_ok", + "github-subject" + ).await; + } + + #[tokio::test] + async fn test_exchange_google_mints_jwt_for_correct_subject() { + assert_exchange_subject( + SubjectTokenType::GoogleIdToken, + "google_ok", + "google-subject" + ).await; + } + + #[tokio::test] + async fn test_exchange_oidc_invalid_token_returns_error() { + assert_exchange_fails( + SubjectTokenType::OidcIdToken, + "invalid" + ).await; + } + + #[tokio::test] + async fn test_exchange_github_invalid_token_returns_error() { + assert_exchange_fails( + SubjectTokenType::GitHubAccessToken, + "invalid" + ).await; + } + + #[tokio::test] + async fn test_exchange_google_invalid_token_returns_error() { + assert_exchange_fails( + SubjectTokenType::GoogleIdToken, + "invalid" + ).await; + } + + #[tokio::test] + async fn test_exchange_empty_token_returns_error() { + assert_exchange_fails( + SubjectTokenType::OidcIdToken, + "" + ).await; + } + + #[tokio::test] + async fn test_exchange_oidc_token_presented_as_github_returns_error() { + assert_exchange_fails( + SubjectTokenType::GitHubAccessToken, + "oidc_ok" + ).await; + } + + #[tokio::test] + async fn test_exchange_github_token_presented_as_oidc_returns_error() { + assert_exchange_fails( + SubjectTokenType::OidcIdToken, + "github_ok" + ).await; + } + + #[tokio::test] + async fn test_exchange_google_token_presented_as_github_returns_error() { + assert_exchange_fails( + SubjectTokenType::GitHubAccessToken, + "google_ok" + ).await; + } } diff --git a/auth/server/src/lib.rs b/auth/server/src/lib.rs index 7f57729..7499980 100644 --- a/auth/server/src/lib.rs +++ b/auth/server/src/lib.rs @@ -661,4 +661,56 @@ pub trait AuthImpl: Send + Sync + 'static { ) }) } + + // =================================== + // = PROVIDER TOKEN EXCHANGE (OAUTH) = + // =================================== + + /// Exchange and validate an OIDC provider ID Token. + /// Returns the OIDC subject (user ID). + fn exchange_and_validate_oidc_token( + &self, + _token: &str, + ) -> DynFuture> { + Box::pin(async { + Err( + anyhow!( + "Must implement 'AuthImpl::exchange_and_validate_oidc_token'." + ) + .into(), + ) + }) + } + + /// Exchange and validate a GitHub access token. + /// Calls GitHub User API, finds or creates user, returns user ID. + fn exchange_and_validate_github_token( + &self, + _token: &str, + ) -> DynFuture> { + Box::pin(async { + Err( + anyhow!( + "Must implement 'AuthImpl::exchange_and_validate_github_token'." + ) + .into(), + ) + }) + } + + /// Exchange and validate a Google ID Token. + /// Verifies JWT signature and claims, finds or creates user, returns user ID. + fn exchange_and_validate_google_token( + &self, + _token: &str, + ) -> DynFuture> { + Box::pin(async { + Err( + anyhow!( + "Must implement 'AuthImpl::exchange_and_validate_google_token'." + ) + .into(), + ) + }) + } } diff --git a/auth/server/src/provider/oidc.rs b/auth/server/src/provider/oidc.rs index 0fddf30..e3c4290 100644 --- a/auth/server/src/provider/oidc.rs +++ b/auth/server/src/provider/oidc.rs @@ -10,7 +10,7 @@ use openidconnect::{ AccessTokenHash, AdditionalClaims, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenFields, - IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, + IssuerUrl, Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, StandardErrorResponse, StandardTokenResponse, TokenResponse as _, core::*, @@ -145,6 +145,40 @@ pub struct OidcProvider { } impl OidcProvider { + fn validate_id_token_claims( + &self, + config: &OidcConfig, + id_token: &openidconnect::IdToken< + UsernameAdditionalClaims, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + nonce_verifier: N, + ) -> anyhow::Result< + openidconnect::IdTokenClaims< + UsernameAdditionalClaims, + CoreGenderClaim, + >, + > { + let verifier = self.client.id_token_verifier(); + let additional_audiences = &config.additional_audiences; + let verifier = if additional_audiences.is_empty() { + verifier + } else { + verifier.set_other_audience_verifier_fn(|aud| { + additional_audiences.contains(aud) + }) + }; + + id_token + .claims(&verifier, nonce_verifier) + .cloned() + .context( + "Failed to verify token claims. This issue may be temporary (60 seconds max).", + ) + } + /// Initialize a new OIDC provider using the configured provider's /// discovery endpoint. pub async fn new( @@ -241,21 +275,10 @@ impl OidcProvider { .id_token() .context("OIDC Server did not return an ID token")?; - // Some providers attach additional audiences, they must be added here - // so token verification succeeds. let verifier = self.client.id_token_verifier(); - let additional_audiences = &config.additional_audiences; - let verifier = if additional_audiences.is_empty() { - verifier - } else { - verifier.set_other_audience_verifier_fn(|aud| { - additional_audiences.contains(aud) - }) - }; - let claims = id_token - .claims(&verifier, nonce) - .context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?; + let claims = self + .validate_id_token_claims(config, id_token, nonce)?; // Verify the access token hash to ensure that the access token hasn't been substituted for // another user's. @@ -460,4 +483,38 @@ impl OidcProvider { // Priority 6 (fallback): use the subject if no others available subject.to_string() } + + /// Validate a raw OIDC ID Token string without a code exchange or nonce check. + /// + /// Validates: JWT signature, issuer, audience, and token expiry. + /// Skips: nonce (not applicable for token exchange), access_token_hash. + /// + /// Returns the validated subject identifier on success. + pub fn validate_id_token_and_extract_subject( + &self, + config: &mogh_auth_client::config::OidcConfig, + id_token_str: &str, + ) -> anyhow::Result { + let id_token: openidconnect::IdToken< + UsernameAdditionalClaims, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + > = id_token_str + .parse() + .context("Failed to parse OIDC ID token")?; + + // No nonce — this is a direct token exchange, not an authorization code flow + let claims = self + .validate_id_token_claims( + config, + &id_token, + |_nonce: Option<&openidconnect::Nonce>| Ok(()), + ) + .context( + "OIDC ID token validation failed — invalid signature, audience, issuer, or expiry", + )?; + + Ok(claims.subject().clone()) + } }