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