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