Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
device.rs

1//! OAuth 2.0 Device Authorization Grant endpoints
2//! ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)).
3//!
4//! Two audiences share this module: `POST /device_authorization` is public and called by
5//! native clients, while the `/device_verification*` endpoints are session-authed (`AuthUser`)
6//! and drive the browser consent page.
7//!
8//! The DB-touching core of each handler lives in a free function, as in `token.rs` /
9//! `token_service.rs`, so it can be unit-tested without actix extractors.
10
11use crate::domain::oauth::helpers::{
12    oauth_invalid_client, oauth_invalid_scope, oauth_unauthorized_client, split_and_validate_scopes,
13};
14use crate::prelude::*;
15use actix_web::{HttpResponse, web};
16use chrono::{Duration, Utc};
17use headless_lms_base::config::ApplicationConfiguration;
18use models::{
19    library::oauth::{
20        Digest, GrantTypeName, generate_access_token, generate_user_code, token_digest_sha256,
21    },
22    oauth_client::OAuthClient,
23    oauth_device_codes::{NewDeviceCodeParams, OAuthDeviceCode},
24    oauth_user_client_scopes::OAuthUserClientScopes,
25};
26use secrecy::SecretString;
27use serde::{Deserialize, Serialize};
28use sqlx::{Connection, PgConnection, PgPool};
29use utoipa::{OpenApi, ToSchema};
30use uuid::Uuid;
31
32#[derive(OpenApi)]
33#[openapi(paths(
34    device_authorization,
35    device_verification,
36    approve_device_verification,
37    deny_device_verification
38))]
39#[allow(dead_code)]
40pub(crate) struct MainFrontendOauthDeviceApiDoc;
41
42/// Device code lifetime. Must stay within the DB CHECK ceiling (30 minutes).
43const DEVICE_CODE_TTL_MINUTES: i64 = 15;
44/// Minimum seconds a client should wait between polls of the token endpoint.
45const DEVICE_CODE_INTERVAL_SECONDS: i32 = 5;
46
47/// Form body for `POST /device_authorization` (RFC 8628 §3.1).
48#[derive(Debug, Deserialize, ToSchema)]
49pub struct DeviceAuthorizationForm {
50    pub client_id: String,
51    /// Space-delimited requested scopes. Optional; when absent the client's
52    /// registered scopes are used.
53    #[serde(default)]
54    pub scope: Option<String>,
55}
56
57/// Success body for `POST /device_authorization` (RFC 8628 §3.2).
58#[derive(Debug, Serialize, ToSchema)]
59pub struct DeviceAuthorizationResponse {
60    pub device_code: String,
61    pub user_code: String,
62    pub verification_uri: String,
63    pub verification_uri_complete: String,
64    pub expires_in: i64,
65    pub interval: i32,
66}
67
68/// Query for the verification page render data (`GET /device_verification`).
69#[derive(Debug, Deserialize, ToSchema)]
70pub struct DeviceVerificationQuery {
71    pub user_code: String,
72}
73
74/// Render data returned to the verification page so it can show the user what
75/// they are about to authorize.
76#[derive(Debug, Serialize, ToSchema)]
77pub struct DeviceVerificationInfo {
78    /// Public client identifier (the `client_id` string, not the internal UUID).
79    pub client_id: String,
80    /// Human-readable client name for display.
81    pub client_name: String,
82    /// Scopes the client is requesting.
83    pub scopes: Vec<String>,
84    /// The normalized `user_code` (`XXXX-XXXX`), echoed back for display.
85    pub user_code: String,
86}
87
88/// Body for the approve/deny verification actions.
89#[derive(Debug, Deserialize, ToSchema)]
90pub struct DeviceDecisionBody {
91    pub user_code: String,
92}
93
94/// Result of an approve/deny action.
95#[derive(Debug, Serialize, ToSchema)]
96pub struct DeviceDecisionResponse {
97    /// `"approved"` or `"denied"`.
98    pub status: String,
99}
100
101/// Normalize a user-entered `user_code` into the canonical `XXXX-XXXX` shape.
102///
103/// Uppercases, strips everything that is not alphanumeric (so a missing
104/// or extra hyphen and surrounding whitespace are tolerated), and re-inserts the
105/// single group separator when exactly eight characters remain. Malformed input
106/// is returned uppercased-and-stripped and will not match any stored code.
107fn normalize_user_code(input: &str) -> String {
108    let cleaned: String = input
109        .chars()
110        .filter(|c| c.is_ascii_alphanumeric())
111        .collect::<String>()
112        .to_uppercase();
113    if cleaned.len() == 8 {
114        format!("{}-{}", &cleaned[..4], &cleaned[4..])
115    } else {
116        cleaned
117    }
118}
119
120/// A `ControllerError` for a `user_code` that has no still-pending grant.
121fn device_code_not_found() -> ControllerError {
122    controller_err!(
123        NotFound,
124        "no pending device authorization for this user_code".to_string()
125    )
126}
127
128/// Resolve and validate the requested scopes against the client's registered
129/// scopes. An empty/absent request defaults to the client's full scope set
130/// (RFC 8628 §3.1 makes `scope` optional).
131fn resolve_device_scopes(
132    client: &OAuthClient,
133    scope: Option<&str>,
134) -> Result<Vec<String>, ControllerError> {
135    match scope {
136        Some(s) if s.split_whitespace().next().is_some() => {
137            split_and_validate_scopes(s, &client.scopes)
138                .map_err(|_| oauth_invalid_scope("requested scope is not allowed"))
139        }
140        _ => Ok(client.scopes.clone()),
141    }
142}
143
144/// Number of times a colliding `user_code` is regenerated before giving up.
145const DEVICE_USER_CODE_MAX_ATTEMPTS: usize = 3;
146
147/// True when `err` is the pending-`user_code` unique-index violation (the
148/// generated code clashed with another still-pending grant). Keyed on the mapped
149/// constraint name rather than string-matching the raw DB message.
150fn is_pending_user_code_collision(err: &models::ModelError) -> bool {
151    matches!(
152        err.error_type(),
153        models::ModelErrorType::DatabaseConstraint { constraint, .. }
154            if constraint == "uq_oauth_device_codes_user_code_pending"
155    )
156}
157
158/// Insert a pending device code, regenerating the `user_code` on collision with
159/// another still-pending grant so a clash cannot surface as a 500.
160///
161/// Each attempt runs inside its own savepoint so a unique-violation rolls back
162/// cleanly and leaves the surrounding connection usable for the next try.
163/// Returns the `user_code` that was successfully stored.
164async fn insert_device_code_retrying_user_code(
165    conn: &mut PgConnection,
166    device_code_digest: &Digest,
167    client_id: Uuid,
168    scopes: &[String],
169    expires_at: chrono::DateTime<Utc>,
170    mut next_user_code: impl FnMut() -> String,
171) -> Result<String, ControllerError> {
172    let mut last_err = None;
173    for _ in 0..DEVICE_USER_CODE_MAX_ATTEMPTS {
174        let user_code = next_user_code();
175        let mut savepoint = conn.begin().await?;
176        let result = OAuthDeviceCode::insert(
177            &mut savepoint,
178            NewDeviceCodeParams {
179                device_code_digest,
180                user_code: &user_code,
181                client_id,
182                scopes,
183                interval_seconds: DEVICE_CODE_INTERVAL_SECONDS,
184                expires_at,
185                metadata: serde_json::Map::new(),
186            },
187        )
188        .await;
189        match result {
190            Ok(()) => {
191                savepoint.commit().await?;
192                return Ok(user_code);
193            }
194            Err(e) if is_pending_user_code_collision(&e) => {
195                savepoint.rollback().await?;
196                last_err = Some(e);
197            }
198            Err(e) => {
199                savepoint.rollback().await?;
200                return Err(e.into());
201            }
202        }
203    }
204    // Every attempt collided; surface the last error.
205    Err(last_err
206        .expect("the retry loop records the collision error on every attempt")
207        .into())
208}
209
210/// Core of `POST /device_authorization`: look up the client, gate on the
211/// device-code grant, validate scopes, generate + store the codes, and build the
212/// RFC 8628 response. `verification_uri` is derived as `{base_url}/oauth_device`.
213async fn create_device_authorization(
214    conn: &mut PgConnection,
215    form: &DeviceAuthorizationForm,
216    token_hmac_key: &SecretString,
217    base_url: &str,
218) -> Result<DeviceAuthorizationResponse, ControllerError> {
219    let client = OAuthClient::find_by_client_id(conn, &form.client_id)
220        .await
221        .map_err(|e| {
222            tracing::warn!(err = %e, "device_authorization: client lookup failed");
223            oauth_invalid_client("invalid client_id")
224        })?;
225
226    if !client.allows_grant(GrantTypeName::DeviceCode) {
227        return Err(oauth_unauthorized_client(
228            "client is not allowed the device_code grant",
229        ));
230    }
231
232    let requested_scopes = resolve_device_scopes(&client, form.scope.as_deref())?;
233
234    // The table only grows through this endpoint, so this is where it shrinks. A failure here must
235    // not fail the device authorization request.
236    match OAuthDeviceCode::delete_expired(conn).await {
237        Ok(0) => {}
238        Ok(deleted) => tracing::info!(deleted, "device_authorization: pruned expired device codes"),
239        Err(e) => {
240            tracing::warn!(err = %e, "device_authorization: pruning expired device codes failed")
241        }
242    }
243
244    let device_code = generate_access_token();
245    let device_code_digest = token_digest_sha256(&device_code, token_hmac_key);
246    let expires_at = Utc::now() + Duration::minutes(DEVICE_CODE_TTL_MINUTES);
247
248    let user_code = insert_device_code_retrying_user_code(
249        conn,
250        &device_code_digest,
251        client.id,
252        &requested_scopes,
253        expires_at,
254        generate_user_code,
255    )
256    .await?;
257
258    let base_url = base_url.trim_end_matches('/');
259    let verification_uri = format!("{}/oauth_device", base_url);
260    let verification_uri_complete = format!("{}?user_code={}", verification_uri, user_code);
261
262    Ok(DeviceAuthorizationResponse {
263        device_code,
264        user_code,
265        verification_uri,
266        verification_uri_complete,
267        expires_in: DEVICE_CODE_TTL_MINUTES * 60,
268        interval: DEVICE_CODE_INTERVAL_SECONDS,
269    })
270}
271
272/// Core of `GET /device_verification`: look up the pending grant for a
273/// (already normalized) `user_code` and gather the render data.
274async fn load_device_verification_info(
275    conn: &mut PgConnection,
276    user_code: &str,
277) -> Result<DeviceVerificationInfo, ControllerError> {
278    let device = OAuthDeviceCode::find_pending_by_user_code(conn, user_code)
279        .await
280        .map_err(|_| device_code_not_found())?;
281    let client = OAuthClient::find_by_id(conn, device.client_id).await?;
282    Ok(DeviceVerificationInfo {
283        client_id: client.client_id,
284        client_name: client.client_name,
285        scopes: device.scopes,
286        user_code: user_code.to_string(),
287    })
288}
289
290/// Core of `POST /device_verification/approve`: persist consent (always, never
291/// short-circuited) then approve the pending grant, binding it to `user_id`.
292async fn approve_device(
293    conn: &mut PgConnection,
294    user_code: &str,
295    user_id: Uuid,
296) -> Result<(), ControllerError> {
297    // Look up the pending grant first so we know which client + scopes to
298    // record consent for.
299    let device = OAuthDeviceCode::find_pending_by_user_code(conn, user_code)
300        .await
301        .map_err(|_| device_code_not_found())?;
302
303    // Record consent unconditionally, like the web consent flow: never short-circuit on an
304    // existing grant.
305    OAuthUserClientScopes::insert(conn, user_id, device.client_id, &device.scopes).await?;
306
307    OAuthDeviceCode::approve(conn, user_code, user_id)
308        .await
309        .map_err(|_| device_code_not_found())?;
310    Ok(())
311}
312
313/// Core of `POST /device_verification/deny`.
314async fn deny_device(conn: &mut PgConnection, user_code: &str) -> Result<(), ControllerError> {
315    OAuthDeviceCode::deny(conn, user_code)
316        .await
317        .map_err(|_| device_code_not_found())?;
318    Ok(())
319}
320
321/// Handles `POST /device_authorization` — the RFC 8628 device authorization
322/// endpoint.
323///
324/// Public, form-encoded, no session required (rate-limited like `/token`).
325/// Validates the client, checks it is allowed the device-code grant, validates
326/// requested scopes against the client's registered scopes, then issues an
327/// opaque `device_code` (stored only as an HMAC digest) plus a human-typable
328/// `user_code`.
329///
330/// Follows [RFC 8628 §3.1–§3.2](https://datatracker.ietf.org/doc/html/rfc8628#section-3.1).
331///
332/// # Example
333/// ```http
334/// POST /api/v0/main-frontend/oauth/device_authorization HTTP/1.1
335/// Content-Type: application/x-www-form-urlencoded
336///
337/// client_id=tmc-vscode&scope=exercise-services
338/// ```
339///
340/// Successful response:
341/// ```http
342/// HTTP/1.1 200 OK
343/// Content-Type: application/json
344///
345/// {
346///   "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
347///   "user_code": "WDJB-MJHT",
348///   "verification_uri": "https://courses.mooc.fi/oauth_device",
349///   "verification_uri_complete": "https://courses.mooc.fi/oauth_device?user_code=WDJB-MJHT",
350///   "expires_in": 900,
351///   "interval": 5
352/// }
353/// ```
354#[instrument(skip(pool, app_conf, form))]
355#[utoipa::path(
356    post,
357    path = "/device_authorization",
358    operation_id = "deviceAuthorizationOauth",
359    tag = "oauth",
360    request_body(
361        content = DeviceAuthorizationForm,
362        content_type = "application/x-www-form-urlencoded"
363    ),
364    responses(
365        (status = 200, description = "Device authorization response", body = DeviceAuthorizationResponse),
366        (status = 400, description = "OAuth error (invalid_scope, unauthorized_client)"),
367        (status = 401, description = "OAuth error (invalid_client)")
368    )
369)]
370pub async fn device_authorization(
371    pool: web::Data<PgPool>,
372    form: web::Form<DeviceAuthorizationForm>,
373    app_conf: web::Data<ApplicationConfiguration>,
374) -> ControllerResult<HttpResponse> {
375    let mut conn = pool.acquire().await?;
376    let server_token = skip_authorize();
377
378    tracing::Span::current().record("client_id", &form.client_id);
379
380    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
381    let response =
382        create_device_authorization(&mut conn, &form, token_hmac_key, &app_conf.base_url).await?;
383
384    server_token.authorized_ok(HttpResponse::Ok().json(response))
385}
386
387/// Handles `GET /device_verification` — render data for the browser consent page.
388///
389/// Session-authed. Looks up the still-pending, unexpired grant for the given
390/// `user_code` (normalizing the input first) and returns the client name and
391/// requested scopes so the page can ask the user to approve. A code that is
392/// unknown, expired, or no longer pending yields `404 Not Found` so the page can
393/// show a distinguishable "invalid or expired code" message.
394#[instrument(skip(pool))]
395#[utoipa::path(
396    get,
397    path = "/device_verification",
398    operation_id = "getOauthDeviceVerification",
399    tag = "oauth",
400    params(
401        ("user_code" = String, Query, description = "The user_code shown to the user by the device")
402    ),
403    responses(
404        (status = 200, description = "Pending device authorization render data", body = DeviceVerificationInfo),
405        (status = 404, description = "No pending device authorization for this user_code")
406    )
407)]
408pub async fn device_verification(
409    pool: web::Data<PgPool>,
410    query: web::Query<DeviceVerificationQuery>,
411    user: AuthUser,
412) -> ControllerResult<HttpResponse> {
413    let mut conn = pool.acquire().await?;
414    let token = skip_authorize();
415    let _ = user; // session presence is the authorization; any signed-in user may view.
416
417    let normalized = normalize_user_code(&query.user_code);
418    let info = load_device_verification_info(&mut conn, &normalized).await?;
419
420    token.authorized_ok(HttpResponse::Ok().json(info))
421}
422
423/// Handles `POST /device_verification/approve`.
424///
425/// Session-authed. Persists the user's consent via the same
426/// `OAuthUserClientScopes::insert` the web consent flow uses (consent is always
427/// recorded — never short-circuited on a pre-existing grant), then marks the
428/// device code approved and bound to the signed-in user.
429#[instrument(skip(pool, body))]
430#[utoipa::path(
431    post,
432    path = "/device_verification/approve",
433    operation_id = "approveOauthDeviceVerification",
434    tag = "oauth",
435    request_body = DeviceDecisionBody,
436    responses(
437        (status = 200, description = "Device authorization approved", body = DeviceDecisionResponse),
438        (status = 404, description = "No pending device authorization for this user_code")
439    )
440)]
441pub async fn approve_device_verification(
442    pool: web::Data<PgPool>,
443    body: web::Json<DeviceDecisionBody>,
444    user: AuthUser,
445) -> ControllerResult<HttpResponse> {
446    let mut conn = pool.acquire().await?;
447    let token = skip_authorize();
448
449    let normalized = normalize_user_code(&body.user_code);
450    approve_device(&mut conn, &normalized, user.id).await?;
451
452    token.authorized_ok(HttpResponse::Ok().json(DeviceDecisionResponse {
453        status: "approved".to_string(),
454    }))
455}
456
457/// Handles `POST /device_verification/deny`.
458///
459/// Session-authed. Marks the pending device code denied; the polling client then
460/// receives `access_denied` at the token endpoint.
461#[instrument(skip(pool, body))]
462#[utoipa::path(
463    post,
464    path = "/device_verification/deny",
465    operation_id = "denyOauthDeviceVerification",
466    tag = "oauth",
467    request_body = DeviceDecisionBody,
468    responses(
469        (status = 200, description = "Device authorization denied", body = DeviceDecisionResponse),
470        (status = 404, description = "No pending device authorization for this user_code")
471    )
472)]
473pub async fn deny_device_verification(
474    pool: web::Data<PgPool>,
475    body: web::Json<DeviceDecisionBody>,
476    user: AuthUser,
477) -> ControllerResult<HttpResponse> {
478    let mut conn = pool.acquire().await?;
479    let token = skip_authorize();
480    let _ = user; // any signed-in user with the code may deny it.
481
482    let normalized = normalize_user_code(&body.user_code);
483    deny_device(&mut conn, &normalized).await?;
484
485    token.authorized_ok(HttpResponse::Ok().json(DeviceDecisionResponse {
486        status: "denied".to_string(),
487    }))
488}
489
490pub fn _add_routes(cfg: &mut web::ServiceConfig) {
491    use crate::domain::rate_limit_middleware_builder::{RateLimit, RateLimitConfig};
492    // Modest per-IP limit shared by every device endpoint, mirroring `/token`.
493    // The verification endpoints are session-authed, so this is defense-in-depth
494    // against user_code guessing/enumeration rather than the primary gate.
495    let device_rate_limit = || {
496        RateLimit::new(RateLimitConfig {
497            per_minute: Some(100),
498            per_hour: Some(500),
499            per_day: Some(2000),
500            per_month: None,
501            ..Default::default()
502        })
503    };
504    cfg.service(
505        web::resource("/device_authorization")
506            .wrap(device_rate_limit())
507            .route(web::post().to(device_authorization)),
508    )
509    .service(
510        web::resource("/device_verification")
511            .wrap(device_rate_limit())
512            .route(web::get().to(device_verification)),
513    )
514    .service(
515        web::resource("/device_verification/approve")
516            .wrap(device_rate_limit())
517            .route(web::post().to(approve_device_verification)),
518    )
519    .service(
520        web::resource("/device_verification/deny")
521            .wrap(device_rate_limit())
522            .route(web::post().to(deny_device_verification)),
523    );
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::test_helper::*;
530    use headless_lms_models::library::oauth::GrantTypeName;
531    use headless_lms_models::library::oauth::pkce::PkceMethod;
532    use headless_lms_models::oauth_client::{
533        ApplicationType, NewClientParams, OAuthClient, TokenEndpointAuthMethod,
534    };
535    use headless_lms_models::oauth_user_client_scopes::OAuthUserClientScopes;
536
537    fn hmac_key() -> SecretString {
538        SecretString::new("test-device-controller-hmac-key".to_string().into())
539    }
540
541    const BASE_URL: &str = "https://courses.mooc.fi";
542
543    async fn insert_client(
544        conn: &mut PgConnection,
545        grants: &[GrantTypeName],
546        scopes: &[String],
547    ) -> OAuthClient {
548        let client_id = format!("cli-{}", &generate_access_token()[..12]);
549        OAuthClient::insert(
550            conn,
551            NewClientParams {
552                client_id: &client_id,
553                client_name: "Device controller test client",
554                application_type: ApplicationType::Native,
555                token_endpoint_auth_method: TokenEndpointAuthMethod::None,
556                client_secret: None,
557                client_secret_expires_at: None,
558                redirect_uris: &["urn:ietf:wg:oauth:2.0:oob".to_string()],
559                post_logout_redirect_uris: None,
560                allowed_grant_types: grants,
561                scopes,
562                require_pkce: true,
563                pkce_methods_allowed: &[PkceMethod::S256],
564                allowed_origins: None,
565                bearer_allowed: true,
566            },
567        )
568        .await
569        .unwrap()
570    }
571
572    fn form(client_id: &str, scope: Option<&str>) -> DeviceAuthorizationForm {
573        DeviceAuthorizationForm {
574            client_id: client_id.to_string(),
575            scope: scope.map(|s| s.to_string()),
576        }
577    }
578
579    #[test]
580    fn normalize_user_code_handles_case_hyphen_and_whitespace() {
581        assert_eq!(normalize_user_code("wdjb-mjht"), "WDJB-MJHT");
582        assert_eq!(normalize_user_code("WDJBMJHT"), "WDJB-MJHT");
583        assert_eq!(normalize_user_code("  wdjb mjht "), "WDJB-MJHT");
584        // Malformed length is left stripped/uppercased and won't match anything.
585        assert_eq!(normalize_user_code("abc"), "ABC");
586    }
587
588    #[actix_web::test]
589    async fn device_authorization_happy_path_issues_codes() {
590        insert_data!(:tx);
591        let client = insert_client(
592            tx.as_mut(),
593            &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
594            &["exercise-services".to_string()],
595        )
596        .await;
597
598        let res = create_device_authorization(
599            tx.as_mut(),
600            &form(&client.client_id, Some("exercise-services")),
601            &hmac_key(),
602            BASE_URL,
603        )
604        .await
605        .expect("device authorization should succeed");
606
607        assert_eq!(res.interval, DEVICE_CODE_INTERVAL_SECONDS);
608        assert_eq!(res.expires_in, DEVICE_CODE_TTL_MINUTES * 60);
609        assert_eq!(res.verification_uri, "https://courses.mooc.fi/oauth_device");
610        assert_eq!(
611            res.verification_uri_complete,
612            format!(
613                "https://courses.mooc.fi/oauth_device?user_code={}",
614                res.user_code
615            )
616        );
617
618        // The pending grant is retrievable by its user_code and carries the scope.
619        let info = load_device_verification_info(tx.as_mut(), &res.user_code)
620            .await
621            .expect("pending grant should be retrievable");
622        assert_eq!(info.scopes, vec!["exercise-services".to_string()]);
623        assert_eq!(info.client_id, client.client_id);
624    }
625
626    #[actix_web::test]
627    async fn device_authorization_empty_scope_defaults_to_client_scopes() {
628        insert_data!(:tx);
629        let client = insert_client(
630            tx.as_mut(),
631            &[GrantTypeName::DeviceCode],
632            &["exercise-services".to_string()],
633        )
634        .await;
635
636        let res = create_device_authorization(
637            tx.as_mut(),
638            &form(&client.client_id, None),
639            &hmac_key(),
640            BASE_URL,
641        )
642        .await
643        .expect("device authorization without scope should default to client scopes");
644
645        let info = load_device_verification_info(tx.as_mut(), &res.user_code)
646            .await
647            .unwrap();
648        assert_eq!(info.scopes, vec!["exercise-services".to_string()]);
649    }
650
651    #[actix_web::test]
652    async fn device_authorization_unknown_client_is_invalid_client() {
653        insert_data!(:tx);
654        let err = create_device_authorization(
655            tx.as_mut(),
656            &form("does-not-exist", None),
657            &hmac_key(),
658            BASE_URL,
659        )
660        .await
661        .expect_err("unknown client should fail");
662        match err.error_type() {
663            ControllerErrorType::OAuthError(data) => assert_eq!(data.error, "invalid_client"),
664            other => panic!("expected OAuthError invalid_client, got {:?}", other),
665        }
666    }
667
668    #[actix_web::test]
669    async fn device_authorization_client_without_grant_is_unauthorized_client() {
670        insert_data!(:tx);
671        let client = insert_client(
672            tx.as_mut(),
673            &[GrantTypeName::RefreshToken],
674            &["exercise-services".to_string()],
675        )
676        .await;
677        let err = create_device_authorization(
678            tx.as_mut(),
679            &form(&client.client_id, None),
680            &hmac_key(),
681            BASE_URL,
682        )
683        .await
684        .expect_err("client without device_code grant should fail");
685        match err.error_type() {
686            ControllerErrorType::OAuthError(data) => assert_eq!(data.error, "unauthorized_client"),
687            other => panic!("expected OAuthError unauthorized_client, got {:?}", other),
688        }
689    }
690
691    #[actix_web::test]
692    async fn device_authorization_invalid_scope_is_rejected() {
693        insert_data!(:tx);
694        let client = insert_client(
695            tx.as_mut(),
696            &[GrantTypeName::DeviceCode],
697            &["exercise-services".to_string()],
698        )
699        .await;
700        let err = create_device_authorization(
701            tx.as_mut(),
702            &form(
703                &client.client_id,
704                Some("exercise-services some-other-scope"),
705            ),
706            &hmac_key(),
707            BASE_URL,
708        )
709        .await
710        .expect_err("scope outside the client's registered set should fail");
711        match err.error_type() {
712            ControllerErrorType::OAuthError(data) => assert_eq!(data.error, "invalid_scope"),
713            other => panic!("expected OAuthError invalid_scope, got {:?}", other),
714        }
715    }
716
717    #[actix_web::test]
718    async fn verification_lookup_missing_code_is_not_found() {
719        insert_data!(:tx);
720        let err = load_device_verification_info(tx.as_mut(), "ZZZZ-ZZZZ")
721            .await
722            .expect_err("unknown user_code should be not found");
723        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
724    }
725
726    #[actix_web::test]
727    async fn approve_persists_consent_and_marks_approved() {
728        insert_data!(:tx, :user);
729        let client = insert_client(
730            tx.as_mut(),
731            &[GrantTypeName::DeviceCode],
732            &["exercise-services".to_string()],
733        )
734        .await;
735        let res = create_device_authorization(
736            tx.as_mut(),
737            &form(&client.client_id, None),
738            &hmac_key(),
739            BASE_URL,
740        )
741        .await
742        .unwrap();
743
744        approve_device(tx.as_mut(), &res.user_code, user)
745            .await
746            .expect("approve should succeed");
747
748        // Consent row persisted for (user, client).
749        let granted = OAuthUserClientScopes::find_scopes(tx.as_mut(), user, client.id)
750            .await
751            .unwrap();
752        assert_eq!(granted, vec!["exercise-services".to_string()]);
753
754        // The grant left the pending state: the verification lookup no longer
755        // finds it, and a second approve fails as not found.
756        let err = load_device_verification_info(tx.as_mut(), &res.user_code)
757            .await
758            .expect_err("approved code is no longer pending");
759        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
760
761        let err = approve_device(tx.as_mut(), &res.user_code, user)
762            .await
763            .expect_err("re-approving a non-pending code should fail");
764        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
765    }
766
767    #[actix_web::test]
768    async fn insert_device_code_retries_past_user_code_collision() {
769        insert_data!(:tx);
770        let client = insert_client(
771            tx.as_mut(),
772            &[GrantTypeName::DeviceCode],
773            &["exercise-services".to_string()],
774        )
775        .await;
776
777        // Pre-seed a pending grant so its user_code is taken.
778        let taken = create_device_authorization(
779            tx.as_mut(),
780            &form(&client.client_id, None),
781            &hmac_key(),
782            BASE_URL,
783        )
784        .await
785        .unwrap();
786
787        // The generator hands out the already-taken code first (forcing a
788        // collision + retry), then a fresh code that must succeed.
789        let fresh = generate_user_code();
790        // pop() drains from the end, so order it [fresh, taken] => taken first.
791        let mut codes = vec![fresh.clone(), taken.user_code.clone()];
792        let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
793        let scopes = vec!["exercise-services".to_string()];
794
795        let stored = insert_device_code_retrying_user_code(
796            tx.as_mut(),
797            &digest,
798            client.id,
799            &scopes,
800            Utc::now() + Duration::minutes(DEVICE_CODE_TTL_MINUTES),
801            || codes.pop().expect("generator ran more than expected"),
802        )
803        .await
804        .expect("a colliding user_code must be regenerated, not surfaced as an error");
805
806        assert_eq!(stored, fresh);
807        // The regenerated code is now the retrievable pending grant.
808        let info = load_device_verification_info(tx.as_mut(), &fresh)
809            .await
810            .expect("the retried grant should be pending");
811        assert_eq!(info.client_id, client.client_id);
812    }
813
814    #[actix_web::test]
815    async fn insert_device_code_gives_up_after_repeated_collisions() {
816        insert_data!(:tx);
817        let client = insert_client(
818            tx.as_mut(),
819            &[GrantTypeName::DeviceCode],
820            &["exercise-services".to_string()],
821        )
822        .await;
823
824        let taken = create_device_authorization(
825            tx.as_mut(),
826            &form(&client.client_id, None),
827            &hmac_key(),
828            BASE_URL,
829        )
830        .await
831        .unwrap();
832        let taken_code = taken.user_code.clone();
833
834        let digest = token_digest_sha256(&generate_access_token(), &hmac_key());
835        let scopes = vec!["exercise-services".to_string()];
836
837        // Always return the taken code: every attempt collides, so the bounded
838        // retry eventually surfaces the error instead of looping forever.
839        let err = insert_device_code_retrying_user_code(
840            tx.as_mut(),
841            &digest,
842            client.id,
843            &scopes,
844            Utc::now() + Duration::minutes(DEVICE_CODE_TTL_MINUTES),
845            || taken_code.clone(),
846        )
847        .await
848        .expect_err("exhausting the retries should surface an error");
849        assert!(matches!(err.error_type(), ControllerErrorType::BadRequest));
850    }
851
852    /// The provisioned `tmc-vscode` client has `require_pkce=true`, forced for public clients,
853    /// but RFC 8628 defines no PKCE binding, so the device grant must ignore it end to end.
854    /// Honoring `require_pkce` here would break the production client silently.
855    #[actix_web::test]
856    async fn require_pkce_is_a_noop_for_the_device_grant_end_to_end() {
857        use crate::domain::oauth::token_query::TokenGrant;
858        use crate::domain::oauth::token_service::{
859            TokenGrantRequest, generate_token_pair, process_token_grant,
860        };
861        use headless_lms_models::oauth_access_token::TokenType;
862        use headless_lms_utils::cache::Cache;
863
864        insert_data!(:tx, :user);
865        let client = insert_client(
866            tx.as_mut(),
867            &[GrantTypeName::DeviceCode, GrantTypeName::RefreshToken],
868            &["exercise-services".to_string()],
869        )
870        .await;
871        assert!(
872            client.require_pkce,
873            "this test only means something if the client forces require_pkce"
874        );
875
876        let key = hmac_key();
877        // Device authorization has no code_challenge parameter at all.
878        let res = create_device_authorization(
879            tx.as_mut(),
880            &form(&client.client_id, None),
881            &key,
882            BASE_URL,
883        )
884        .await
885        .expect("device authorization should succeed with no PKCE input");
886
887        // User approves in the browser.
888        approve_device(tx.as_mut(), &res.user_code, user)
889            .await
890            .expect("approve should succeed");
891
892        // Redeem the approved device code with NO PKCE verifier.
893        let grant = TokenGrant::DeviceCode {
894            device_code: res.device_code.clone().into(),
895        };
896        let request = TokenGrantRequest {
897            grant: &grant,
898            client: &client,
899            token_pair: generate_token_pair(&key),
900            access_expires_at: Utc::now() + Duration::hours(1),
901            refresh_expires_at: Utc::now() + Duration::days(30),
902            issued_token_type: TokenType::Bearer,
903            dpop_jkt: None,
904            token_hmac_key: &key,
905        };
906        let result = process_token_grant(
907            tx.as_mut(),
908            &Cache::new("redis://127.0.0.1:1").expect("cache"),
909            request,
910        )
911        .await
912        .expect("device grant must succeed without a PKCE verifier despite require_pkce=true");
913        assert_eq!(result.user_id, user);
914        assert_eq!(result.scopes, vec!["exercise-services".to_string()]);
915
916        tx.rollback().await;
917    }
918
919    #[actix_web::test]
920    async fn deny_marks_denied_and_is_not_found_afterwards() {
921        insert_data!(:tx, :user);
922        let _ = user;
923        let client = insert_client(
924            tx.as_mut(),
925            &[GrantTypeName::DeviceCode],
926            &["exercise-services".to_string()],
927        )
928        .await;
929        let res = create_device_authorization(
930            tx.as_mut(),
931            &form(&client.client_id, None),
932            &hmac_key(),
933            BASE_URL,
934        )
935        .await
936        .unwrap();
937
938        deny_device(tx.as_mut(), &res.user_code)
939            .await
940            .expect("deny should succeed");
941
942        // No longer pending -> lookup for the verification page is not found.
943        let err = load_device_verification_info(tx.as_mut(), &res.user_code)
944            .await
945            .expect_err("denied code is not pending");
946        assert!(matches!(err.error_type(), ControllerErrorType::NotFound));
947    }
948}