kanidmd_core/https/views/
oauth2.rs

1use crate::https::{
2    extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation},
3    middleware::KOpId,
4    ServerState,
5};
6use kanidmd_lib::idm::oauth2::{AuthorisationRequest, AuthoriseResponse, Oauth2Error};
7use kanidmd_lib::prelude::*;
8
9use kanidm_proto::internal::COOKIE_OAUTH2_REQ;
10
11use std::collections::BTreeSet;
12
13use askama::Template;
14use askama_web::WebTemplate;
15
16#[cfg(feature = "dev-oauth2-device-flow")]
17use axum::http::StatusCode;
18use axum::{
19    extract::{Query, State},
20    http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
21    response::{IntoResponse, Redirect, Response},
22    Extension, Form,
23};
24use axum_extra::extract::cookie::{CookieJar, SameSite};
25use axum_htmx::HX_REDIRECT;
26use serde::Deserialize;
27
28use super::login::{LoginDisplayCtx, Oauth2Ctx};
29use super::{cookies, UnrecoverableErrorView};
30
31#[derive(Template, WebTemplate)]
32#[template(path = "oauth2_consent_request.html")]
33struct ConsentRequestView {
34    client_name: String,
35    // scopes: BTreeSet<String>,
36    pii_scopes: BTreeSet<String>,
37    consent_token: String,
38    redirect: Option<String>,
39}
40
41#[derive(Template, WebTemplate)]
42#[template(path = "oauth2_access_denied.html")]
43struct AccessDeniedView {
44    operation_id: Uuid,
45}
46
47pub async fn view_index_get(
48    State(state): State<ServerState>,
49    Extension(kopid): Extension<KOpId>,
50    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
51    DomainInfo(domain_info): DomainInfo,
52    jar: CookieJar,
53    Query(auth_req): Query<AuthorisationRequest>,
54) -> Response {
55    oauth2_auth_req(
56        state,
57        kopid,
58        client_auth_info,
59        domain_info,
60        jar,
61        Some(auth_req),
62    )
63    .await
64}
65
66pub async fn view_resume_get(
67    State(state): State<ServerState>,
68    Extension(kopid): Extension<KOpId>,
69    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
70    DomainInfo(domain_info): DomainInfo,
71    jar: CookieJar,
72) -> Response {
73    let maybe_auth_req =
74        cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
75
76    oauth2_auth_req(
77        state,
78        kopid,
79        client_auth_info,
80        domain_info,
81        jar,
82        maybe_auth_req,
83    )
84    .await
85}
86
87async fn oauth2_auth_req(
88    state: ServerState,
89    kopid: KOpId,
90    client_auth_info: ClientAuthInfo,
91    domain_info: DomainInfoRead,
92    jar: CookieJar,
93    maybe_auth_req: Option<AuthorisationRequest>,
94) -> Response {
95    // No matter what, we always clear the stored oauth2 cookie to prevent
96    // ui loops
97    let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &state);
98
99    // If the auth_req was cross-signed, old, or just bad, error. But we have *cleared* it
100    // from the cookie which means we won't see it again.
101    let Some(auth_req) = maybe_auth_req else {
102        error!("unable to resume session, no valid auth_req was found in the cookie. This cookie has been removed.");
103        return (
104            jar,
105            UnrecoverableErrorView {
106                err_code: OperationError::UI0003InvalidOauth2Resume,
107                operation_id: kopid.eventid,
108                domain_info,
109            },
110        )
111            .into_response();
112    };
113
114    let res: Result<AuthoriseResponse, Oauth2Error> = state
115        .qe_r_ref
116        .handle_oauth2_authorise(client_auth_info, auth_req.clone(), kopid.eventid)
117        .await;
118
119    match res {
120        Ok(AuthoriseResponse::Permitted(success)) => {
121            let redirect_uri = success.build_redirect_uri();
122
123            (
124                jar,
125                [
126                    (HX_REDIRECT, redirect_uri.as_str().to_string()),
127                    (
128                        ACCESS_CONTROL_ALLOW_ORIGIN,
129                        redirect_uri.origin().ascii_serialization(),
130                    ),
131                ],
132                Redirect::to(redirect_uri.as_str()),
133            )
134                .into_response()
135        }
136        Ok(AuthoriseResponse::ConsentRequested {
137            client_name,
138            scopes: _,
139            pii_scopes,
140            consent_token,
141        }) => {
142            // We can just render the form now, the consent token has everything we need.
143            (
144                jar,
145                ConsentRequestView {
146                    client_name,
147                    // scopes,
148                    pii_scopes,
149                    consent_token,
150                    redirect: None,
151                },
152            )
153                .into_response()
154        }
155
156        Ok(AuthoriseResponse::AuthenticationRequired {
157            client_name,
158            login_hint,
159        }) => {
160            // Sign the auth req and hide it in our cookie - we'll come back for
161            // you later.
162            let maybe_jar = cookies::make_signed(&state, COOKIE_OAUTH2_REQ, &auth_req)
163                .map(|mut cookie| {
164                    cookie.set_same_site(SameSite::Strict);
165                    // Expire at the end of the session.
166                    cookie.set_expires(None);
167                    // Could experiment with this to a shorter value, but session should be enough.
168                    cookie.set_max_age(time::Duration::minutes(15));
169                    jar.clone().add(cookie)
170                })
171                .ok_or(OperationError::InvalidSessionState);
172
173            match maybe_jar {
174                Ok(new_jar) => {
175                    let display_ctx = LoginDisplayCtx {
176                        domain_info,
177                        oauth2: Some(Oauth2Ctx { client_name }),
178                        reauth: None,
179                        error: None,
180                    };
181
182                    super::login::view_oauth2_get(new_jar, display_ctx, login_hint)
183                }
184                Err(err_code) => (
185                    jar,
186                    UnrecoverableErrorView {
187                        err_code,
188                        operation_id: kopid.eventid,
189                        domain_info,
190                    },
191                )
192                    .into_response(),
193            }
194        }
195        Err(Oauth2Error::AccessDenied) => {
196            // If scopes are not available for this account.
197            (
198                jar,
199                AccessDeniedView {
200                    operation_id: kopid.eventid,
201                },
202            )
203                .into_response()
204        }
205        /*
206        RFC - If the request fails due to a missing, invalid, or mismatching
207              redirection URI, or if the client identifier is missing or invalid,
208              the authorization server SHOULD inform the resource owner of the
209              error and MUST NOT automatically redirect the user-agent to the
210              invalid redirection URI.
211        */
212        // To further this, it appears that a malicious client configuration can set a phishing
213        // site as the redirect URL, and then use that to trigger certain types of attacks. Instead
214        // we do NOT redirect in an error condition, and just render the error ourselves.
215        Err(err_code) => {
216            error!(
217                "Unable to authorise - Error ID: {:?} error: {}",
218                kopid.eventid,
219                &err_code.to_string()
220            );
221
222            (
223                jar,
224                UnrecoverableErrorView {
225                    err_code: OperationError::InvalidState,
226                    operation_id: kopid.eventid,
227                    domain_info,
228                },
229            )
230                .into_response()
231        }
232    }
233}
234
235#[derive(Debug, Clone, Deserialize)]
236pub struct ConsentForm {
237    consent_token: String,
238    #[serde(default)]
239    #[allow(dead_code)] // TODO: do something with this
240    redirect: Option<String>,
241}
242
243pub async fn view_consent_post(
244    State(server_state): State<ServerState>,
245    Extension(kopid): Extension<KOpId>,
246    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
247    DomainInfo(domain_info): DomainInfo,
248    jar: CookieJar,
249    Form(consent_form): Form<ConsentForm>,
250) -> Result<Response, UnrecoverableErrorView> {
251    let res = server_state
252        .qe_w_ref
253        .handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid)
254        .await;
255
256    match res {
257        Ok(success) => {
258            let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &server_state);
259
260            if let Some(redirect) = consent_form.redirect {
261                Ok((
262                    jar,
263                    [
264                        (HX_REDIRECT, success.redirect_uri.as_str().to_string()),
265                        (
266                            ACCESS_CONTROL_ALLOW_ORIGIN,
267                            success.redirect_uri.origin().ascii_serialization(),
268                        ),
269                    ],
270                    Redirect::to(&redirect),
271                )
272                    .into_response())
273            } else {
274                let redirect_uri = success.build_redirect_uri();
275                Ok((
276                    jar,
277                    [
278                        (HX_REDIRECT, redirect_uri.as_str().to_string()),
279                        (
280                            ACCESS_CONTROL_ALLOW_ORIGIN,
281                            redirect_uri.origin().ascii_serialization(),
282                        ),
283                    ],
284                    Redirect::to(redirect_uri.as_str()),
285                )
286                    .into_response())
287            }
288        }
289        Err(err_code) => {
290            error!(
291                "Unable to authorise - Error ID: {:?} error: {}",
292                kopid.eventid,
293                &err_code.to_string()
294            );
295
296            Err(UnrecoverableErrorView {
297                err_code: OperationError::InvalidState,
298                operation_id: kopid.eventid,
299                domain_info,
300            })
301        }
302    }
303}
304
305#[derive(Template, Debug, Clone, WebTemplate)]
306#[cfg(feature = "dev-oauth2-device-flow")]
307#[template(path = "oauth2_device_login.html")]
308pub struct Oauth2DeviceLoginView {
309    domain_custom_image: bool,
310    title: String,
311    user_code: String,
312}
313
314#[derive(Debug, Clone, Deserialize)]
315#[cfg(feature = "dev-oauth2-device-flow")]
316pub(crate) struct QueryUserCode {
317    pub user_code: Option<String>,
318}
319
320#[axum::debug_handler]
321#[cfg(feature = "dev-oauth2-device-flow")]
322pub async fn view_device_get(
323    State(state): State<ServerState>,
324    Extension(_kopid): Extension<KOpId>,
325    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
326    Query(user_code): Query<QueryUserCode>,
327) -> Result<Oauth2DeviceLoginView, (StatusCode, String)> {
328    // TODO: if we have a valid auth session and the user code is valid, prompt the user to allow the session to start
329    Ok(Oauth2DeviceLoginView {
330        domain_custom_image: state.qe_r_ref.domain_info_read().has_custom_image(),
331        title: "Device Login".to_string(),
332        user_code: user_code.user_code.unwrap_or("".to_string()),
333    })
334}
335
336#[derive(Deserialize)]
337#[cfg(feature = "dev-oauth2-device-flow")]
338pub struct Oauth2DeviceLoginForm {
339    user_code: String,
340    confirm_login: bool,
341}
342
343#[cfg(feature = "dev-oauth2-device-flow")]
344#[axum::debug_handler]
345pub async fn view_device_post(
346    State(_state): State<ServerState>,
347    Extension(_kopid): Extension<KOpId>,
348    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
349    Form(form): Form<Oauth2DeviceLoginForm>,
350) -> Result<String, (StatusCode, &'static str)> {
351    debug!("User code: {}", form.user_code);
352    debug!("User confirmed: {}", form.confirm_login);
353
354    // TODO: when the user POST's this form we need to check the user code and see if it's valid
355    // then start a login flow which ends up authorizing the token at the end.
356    Err((StatusCode::NOT_IMPLEMENTED, "Not implemented yet"))
357}