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