1use 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
42const DEVICE_CODE_TTL_MINUTES: i64 = 15;
44const DEVICE_CODE_INTERVAL_SECONDS: i32 = 5;
46
47#[derive(Debug, Deserialize, ToSchema)]
49pub struct DeviceAuthorizationForm {
50 pub client_id: String,
51 #[serde(default)]
54 pub scope: Option<String>,
55}
56
57#[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#[derive(Debug, Deserialize, ToSchema)]
70pub struct DeviceVerificationQuery {
71 pub user_code: String,
72}
73
74#[derive(Debug, Serialize, ToSchema)]
77pub struct DeviceVerificationInfo {
78 pub client_id: String,
80 pub client_name: String,
82 pub scopes: Vec<String>,
84 pub user_code: String,
86}
87
88#[derive(Debug, Deserialize, ToSchema)]
90pub struct DeviceDecisionBody {
91 pub user_code: String,
92}
93
94#[derive(Debug, Serialize, ToSchema)]
96pub struct DeviceDecisionResponse {
97 pub status: String,
99}
100
101fn 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
120fn device_code_not_found() -> ControllerError {
122 controller_err!(
123 NotFound,
124 "no pending device authorization for this user_code".to_string()
125 )
126}
127
128fn 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
144const DEVICE_USER_CODE_MAX_ATTEMPTS: usize = 3;
146
147fn 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
158async 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 Err(last_err
206 .expect("the retry loop records the collision error on every attempt")
207 .into())
208}
209
210async 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 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
272async 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
290async fn approve_device(
293 conn: &mut PgConnection,
294 user_code: &str,
295 user_id: Uuid,
296) -> Result<(), ControllerError> {
297 let device = OAuthDeviceCode::find_pending_by_user_code(conn, user_code)
300 .await
301 .map_err(|_| device_code_not_found())?;
302
303 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
313async 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#[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#[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; 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#[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#[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; 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 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 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 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 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 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 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 let fresh = generate_user_code();
790 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 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 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 #[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 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 approve_device(tx.as_mut(), &res.user_code, user)
889 .await
890 .expect("approve should succeed");
891
892 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 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}