Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
discovery.rs

1use crate::domain::oauth::jwks::{Jwk, Jwks};
2use crate::domain::oauth::oidc::rsa_n_e_and_kid_from_pem;
3use crate::prelude::*;
4use actix_web::{HttpResponse, web};
5use models::library::oauth::scopes::supported_scopes;
6use utoipa::OpenApi;
7
8#[derive(OpenApi)]
9#[openapi(paths(jwks, well_known_openid))]
10#[allow(dead_code)]
11pub(crate) struct MainFrontendOauthDiscoveryApiDoc;
12
13/// Handles `/jwks.json` for returning the JSON Web Key Set (JWKS).
14///
15/// This endpoint:
16/// - Reads the configured ID Token signing public key (RS256).
17/// - Exposes it in JWKS format for clients to validate ID tokens.
18///
19/// Follows [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517).
20///
21/// Note: Currently exposes a single signing key. Key rotation (OIDC Core §10) is not implemented.
22///
23/// # Example
24/// ```http
25/// GET /api/v0/main-frontend/oauth/jwks.json HTTP/1.1
26/// ```
27///
28/// Response:
29/// ```http
30/// HTTP/1.1 200 OK
31/// Content-Type: application/json
32///
33/// {
34///   "keys": [
35///     { "kty":"RSA","use":"sig","alg":"RS256","kid":"abc123","n":"...","e":"AQAB" }
36///   ]
37/// }
38/// ```
39#[instrument(skip(app_conf))]
40#[utoipa::path(
41    get,
42    path = "/jwks.json",
43    operation_id = "getOauthJwks",
44    tag = "oauth",
45    responses(
46        (status = 200, description = "OAuth JSON Web Key Set", body = serde_json::Value)
47    )
48)]
49pub async fn jwks(app_conf: web::Data<ApplicationConfiguration>) -> ControllerResult<HttpResponse> {
50    let server_token = skip_authorize();
51
52    // The public key used for signing ID tokens (RS256)
53    let public_pem = &app_conf.oauth_server_configuration.rsa_public_key;
54
55    // Extract modulus (n), exponent (e), and a stable key id (kid) from the PEM
56    let (n, e, kid) = rsa_n_e_and_kid_from_pem(public_pem)?;
57
58    // Your existing JWKS types
59    let jwk = Jwk {
60        kty: "RSA".into(),
61        use_: "sig".into(),
62        alg: "RS256".into(),
63        kid,
64        n,
65        e,
66    };
67
68    server_token.authorized_ok(HttpResponse::Ok().json(Jwks { keys: vec![jwk] }))
69}
70
71/// Handles `/.well-known/openid-configuration` to expose OIDC discovery metadata.
72///
73/// This endpoint advertises the AS/OP capabilities so clients can auto-configure:
74/// - Endpoints (authorize, token, userinfo, jwks)
75/// - Supported response/grant types
76/// - Token endpoint auth methods
77/// - ID Token signing algs
78/// - PKCE and DPoP metadata
79///
80/// Follows:
81/// - [OIDC Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
82/// - [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://www.rfc-editor.org/rfc/rfc8414)
83/// - [RFC 9449 — DPoP metadata](https://www.rfc-editor.org/rfc/rfc9449#name-authorization-server-metadata)
84///
85/// # Example
86/// ```http
87/// GET /api/v0/main-frontend/oauth/.well-known/openid-configuration HTTP/1.1
88/// ```
89///
90/// Example response (truncated):
91/// ```json
92/// {
93///   "issuer": "https://example.org/api/v0/main-frontend/oauth",
94///   "authorization_endpoint": "https://example.org/api/v0/main-frontend/oauth/authorize",
95///   "token_endpoint": "https://example.org/api/v0/main-frontend/oauth/token",
96///   "userinfo_endpoint": "https://example.org/api/v0/main-frontend/oauth/userinfo",
97///   "jwks_uri": "https://example.org/api/v0/main-frontend/oauth/jwks.json",
98///   "response_types_supported": ["code"],
99///   "grant_types_supported": ["authorization_code","refresh_token"],
100///   "code_challenge_methods_supported": ["S256"],
101///   "token_endpoint_auth_methods_supported": ["none","client_secret_post"],
102///   "id_token_signing_alg_values_supported": ["RS256"],
103///   "subject_types_supported": ["public"],
104///   "dpop_signing_alg_values_supported": ["ES256","RS256"]
105/// }
106/// ```
107#[instrument(skip(app_conf))]
108#[utoipa::path(
109    get,
110    path = "/.well-known/openid-configuration",
111    operation_id = "getOauthOpenidConfiguration",
112    tag = "oauth",
113    responses(
114        (status = 200, description = "OAuth/OpenID Connect discovery document", body = serde_json::Value)
115    )
116)]
117pub async fn well_known_openid(
118    app_conf: web::Data<ApplicationConfiguration>,
119) -> ControllerResult<HttpResponse> {
120    let server_token = skip_authorize();
121    server_token.authorized_ok(HttpResponse::Ok().json(authorization_server_metadata(&app_conf)))
122}
123
124/// The authorization server metadata, served both as OIDC discovery and at the RFC 8414
125/// well-known location.
126pub(crate) fn authorization_server_metadata(
127    app_conf: &ApplicationConfiguration,
128) -> serde_json::Value {
129    let issuer = app_conf.oauth_issuer();
130    // We advertise what the server *globally* supports. Per-client specifics (like allowed PKCE methods)
131    // can be stricter; by default we allow only S256 for PKCE at the server level.
132    let config = serde_json::json!({
133        "authorization_endpoint":          format!("{issuer}/authorize"),
134        "token_endpoint":                  format!("{issuer}/token"),
135        "userinfo_endpoint":               format!("{issuer}/userinfo"),
136        "revocation_endpoint":             format!("{issuer}/revoke"),
137        "device_authorization_endpoint":   format!("{issuer}/device_authorization"),
138        "jwks_uri":                        format!("{issuer}/jwks.json"),
139        "issuer":                          issuer,
140
141        // Core capabilities
142        "response_types_supported":        ["code"],
143        "grant_types_supported":           ["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:device_code"],
144        "subject_types_supported":         ["public"],
145        "id_token_signing_alg_values_supported": ["RS256"],
146
147        "request_parameter_supported": false,
148        "request_uri_parameter_supported": false,
149        "request_object_signing_alg_values_supported": ["RS256"],
150
151        // Token endpoint auth: public ("none") and confidential via client_secret_post
152        "token_endpoint_auth_methods_supported": ["none","client_secret_post"],
153
154        // Client ID Metadata Documents: an https client_id is fetched as the client's registration
155        "client_id_metadata_document_supported": true,
156
157        // RFC 9207: authorization responses carry `iss`
158        "authorization_response_iss_parameter_supported": true,
159
160        // PKCE (RFC 7636): server supports S256; "plain" discouraged and typically disabled
161        "code_challenge_methods_supported": ["S256"],
162
163        // DPoP (RFC 9449) metadata
164        "dpop_signing_alg_values_supported": ["ES256","RS256"],
165
166        // Nice-to-have hints for clients (optional but common)
167        "scopes_supported":                supported_scopes(),
168        "claims_supported":                ["sub","iss","aud","exp","iat","auth_time","nonce","email","email_verified","name","given_name","family_name"],
169        "response_modes_supported":        ["query"],
170        "userinfo_signing_alg_values_supported": [], // we return plain JSON at /userinfo
171    });
172    config
173}
174
175pub fn _add_routes(cfg: &mut web::ServiceConfig) {
176    cfg.route(
177        "/.well-known/openid-configuration",
178        web::get().to(well_known_openid),
179    )
180    .route("/jwks.json", web::get().to(jwks));
181}