Implement device flow and client autoregistration

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2026-01-06 22:24:09 +01:00
parent 3cf557d310
commit badb5dd18e
No known key found for this signature in database
10 changed files with 1214 additions and 5 deletions

View file

@ -41,7 +41,8 @@
"Bash(sqlite3:*)", "Bash(sqlite3:*)",
"Bash(rustc:*)", "Bash(rustc:*)",
"Bash(docker build:*)", "Bash(docker build:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfix(docker): Add missing client-wasm directory and update Rust version\n\n- Add COPY client-wasm to Dockerfile to include workspace member\n- Update Rust base image from 1.91 to 1.92\n- Fixes CI build failure: \"failed to load manifest for workspace member client-wasm\"\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>\nEOF\n)\")" "Bash(git commit -m \"$(cat <<''EOF''\nfix(docker): Add missing client-wasm directory and update Rust version\n\n- Add COPY client-wasm to Dockerfile to include workspace member\n- Update Rust base image from 1.91 to 1.92\n- Fixes CI build failure: \"failed to load manifest for workspace member client-wasm\"\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>\nEOF\n)\")",
"WebFetch(domain:datatracker.ietf.org)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View file

@ -61,6 +61,7 @@ tower-http = { version = "0.6", features = ["fs"] }
# Validation # Validation
regex = "1" regex = "1"
url = "2" url = "2"
urlencoding = "2"
# GraphQL Admin API # GraphQL Admin API
seaography = { version = "1", features = ["with-decimal", "with-chrono", "with-uuid"] } seaography = { version = "1", features = ["with-decimal", "with-chrono", "with-uuid"] }

View file

@ -4,6 +4,7 @@ mod m20250101_000001_initial_schema;
mod m20250107_000001_add_passkeys; mod m20250107_000001_add_passkeys;
mod m20250107_000002_extend_sessions_users; mod m20250107_000002_extend_sessions_users;
mod m20250108_000001_add_consent_table; mod m20250108_000001_add_consent_table;
mod m20250109_000001_add_device_codes;
pub struct Migrator; pub struct Migrator;
@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250107_000001_add_passkeys::Migration), Box::new(m20250107_000001_add_passkeys::Migration),
Box::new(m20250107_000002_extend_sessions_users::Migration), Box::new(m20250107_000002_extend_sessions_users::Migration),
Box::new(m20250108_000001_add_consent_table::Migration), Box::new(m20250108_000001_add_consent_table::Migration),
Box::new(m20250109_000001_add_device_codes::Migration),
] ]
} }
} }

View file

@ -0,0 +1,121 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create device_codes table for OAuth 2.0 Device Authorization Grant (RFC 8628)
manager
.create_table(
Table::create()
.table(DeviceCode::Table)
.if_not_exists()
.col(
ColumnDef::new(DeviceCode::DeviceCode)
.string()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(DeviceCode::UserCode).string().not_null())
.col(ColumnDef::new(DeviceCode::ClientId).string().not_null())
.col(ColumnDef::new(DeviceCode::ClientName).string())
.col(ColumnDef::new(DeviceCode::Scope).string().not_null())
.col(ColumnDef::new(DeviceCode::DeviceInfo).string())
.col(
ColumnDef::new(DeviceCode::CreatedAt)
.big_integer()
.not_null(),
)
.col(
ColumnDef::new(DeviceCode::ExpiresAt)
.big_integer()
.not_null(),
)
.col(ColumnDef::new(DeviceCode::LastPollAt).big_integer())
.col(
ColumnDef::new(DeviceCode::Interval)
.integer()
.not_null()
.default(5),
)
.col(
ColumnDef::new(DeviceCode::Status)
.string()
.not_null()
.default("pending"),
)
.col(ColumnDef::new(DeviceCode::Subject).string())
.col(ColumnDef::new(DeviceCode::AuthTime).big_integer())
.col(ColumnDef::new(DeviceCode::Amr).string())
.col(ColumnDef::new(DeviceCode::Acr).string())
.to_owned(),
)
.await?;
// Create index on user_code for fast lookups during verification
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_device_codes_user_code")
.table(DeviceCode::Table)
.col(DeviceCode::UserCode)
.to_owned(),
)
.await?;
// Create index on expires_at for efficient cleanup job
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_device_codes_expires_at")
.table(DeviceCode::Table)
.col(DeviceCode::ExpiresAt)
.to_owned(),
)
.await?;
// Create index on status for filtering pending/approved codes
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_device_codes_status")
.table(DeviceCode::Table)
.col(DeviceCode::Status)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(DeviceCode::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum DeviceCode {
Table,
DeviceCode,
UserCode,
ClientId,
ClientName,
Scope,
DeviceInfo,
CreatedAt,
ExpiresAt,
LastPollAt,
Interval,
Status,
Subject,
AuthTime,
Amr,
Acr,
}

View file

@ -1,7 +1,7 @@
use async_graphql::*; use async_graphql::*;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter,
QueryOrder, QuerySelect, Set, QuerySelect, Set,
}; };
use std::sync::Arc; use std::sync::Arc;
@ -172,6 +172,11 @@ impl AdminQuery {
description: "Clean up expired WebAuthn challenges".to_string(), description: "Clean up expired WebAuthn challenges".to_string(),
schedule: "Every 5 minutes".to_string(), schedule: "Every 5 minutes".to_string(),
}, },
JobInfo {
name: "cleanup_expired_device_codes".to_string(),
description: "Clean up expired device authorization codes".to_string(),
schedule: "Hourly at :45".to_string(),
},
]) ])
} }

View file

@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "device_codes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub device_code: String,
pub user_code: String,
pub client_id: String,
pub client_name: Option<String>,
pub scope: String,
pub device_info: Option<String>, // JSON: {ip_address, user_agent}
pub created_at: i64,
pub expires_at: i64,
pub last_poll_at: Option<i64>,
pub interval: i64,
pub status: String, // "pending" | "approved" | "denied" | "consumed"
pub subject: Option<String>,
pub auth_time: Option<i64>,
pub amr: Option<String>, // JSON array: ["pwd", "hwk"]
pub acr: Option<String>, // "aal1" or "aal2"
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -2,6 +2,7 @@ pub mod access_token;
pub mod auth_code; pub mod auth_code;
pub mod client; pub mod client;
pub mod consent; pub mod consent;
pub mod device_code;
pub mod job_execution; pub mod job_execution;
pub mod passkey; pub mod passkey;
pub mod property; pub mod property;
@ -14,6 +15,7 @@ pub use access_token::Entity as AccessToken;
pub use auth_code::Entity as AuthCode; pub use auth_code::Entity as AuthCode;
pub use client::Entity as Client; pub use client::Entity as Client;
pub use consent::Entity as Consent; pub use consent::Entity as Consent;
pub use device_code::Entity as DeviceCode;
pub use job_execution::Entity as JobExecution; pub use job_execution::Entity as JobExecution;
pub use passkey::Entity as Passkey; pub use passkey::Entity as Passkey;
pub use property::Entity as Property; pub use property::Entity as Property;

View file

@ -123,13 +123,49 @@ pub async fn init_scheduler(db: DatabaseConnection) -> Result<JobScheduler, Crab
.await .await
.map_err(|e| CrabError::Other(format!("Failed to add cleanup challenges job: {}", e)))?; .map_err(|e| CrabError::Other(format!("Failed to add cleanup challenges job: {}", e)))?;
let db_clone = db.clone();
// Cleanup expired device codes job - runs every hour at :45
let cleanup_device_codes_job = Job::new_async("0 45 * * * *", move |_uuid, _l| {
let db = db_clone.clone();
Box::pin(async move {
info!("Running cleanup_expired_device_codes job");
let execution_id = start_job_execution(&db, "cleanup_expired_device_codes")
.await
.ok();
match storage::cleanup_expired_device_codes(&db).await {
Ok(count) => {
info!("Cleaned up {} expired device codes", count);
if let Some(id) = execution_id {
let _ =
complete_job_execution(&db, id, true, None, Some(count as i64)).await;
}
}
Err(e) => {
error!("Failed to cleanup expired device codes: {}", e);
if let Some(id) = execution_id {
let _ =
complete_job_execution(&db, id, false, Some(e.to_string()), None).await;
}
}
}
})
})
.map_err(|e| CrabError::Other(format!("Failed to create cleanup device codes job: {}", e)))?;
sched
.add(cleanup_device_codes_job)
.await
.map_err(|e| CrabError::Other(format!("Failed to add cleanup device codes job: {}", e)))?;
// Start the scheduler // Start the scheduler
sched sched
.start() .start()
.await .await
.map_err(|e| CrabError::Other(format!("Failed to start job scheduler: {}", e)))?; .map_err(|e| CrabError::Other(format!("Failed to start job scheduler: {}", e)))?;
info!("Job scheduler started with {} jobs", 3); info!("Job scheduler started with {} jobs", 4);
Ok(sched) Ok(sched)
} }
@ -197,6 +233,7 @@ pub async fn trigger_job_manually(
"cleanup_expired_sessions" => storage::cleanup_expired_sessions(db).await, "cleanup_expired_sessions" => storage::cleanup_expired_sessions(db).await,
"cleanup_expired_refresh_tokens" => storage::cleanup_expired_refresh_tokens(db).await, "cleanup_expired_refresh_tokens" => storage::cleanup_expired_refresh_tokens(db).await,
"cleanup_expired_challenges" => storage::cleanup_expired_challenges(db).await, "cleanup_expired_challenges" => storage::cleanup_expired_challenges(db).await,
"cleanup_expired_device_codes" => storage::cleanup_expired_device_codes(db).await,
_ => { _ => {
return Err(CrabError::Other(format!("Unknown job name: {}", job_name))); return Err(CrabError::Other(format!("Unknown job name: {}", job_name)));
} }

View file

@ -3,7 +3,7 @@ use crate::errors::CrabError;
use crate::settings::Database as DbCfg; use crate::settings::Database as DbCfg;
use base64ct::Encoding; use base64ct::Encoding;
use chrono::Utc; use chrono::Utc;
use rand::RngCore; use rand::{Rng, RngCore};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, QueryFilter, ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, QueryFilter,
QueryOrder, Set, QueryOrder, Set,
@ -92,6 +92,25 @@ pub struct RefreshToken {
pub parent_token: Option<String>, // For token rotation tracking pub parent_token: Option<String>, // For token rotation tracking
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub client_id: String,
pub client_name: Option<String>,
pub scope: String,
pub device_info: Option<String>, // JSON: {ip_address, user_agent}
pub created_at: i64,
pub expires_at: i64,
pub last_poll_at: Option<i64>,
pub interval: i64,
pub status: String, // "pending" | "approved" | "denied" | "consumed"
pub subject: Option<String>,
pub auth_time: Option<i64>,
pub amr: Option<String>, // JSON array: ["pwd", "hwk"]
pub acr: Option<String>, // "aal1" or "aal2"
}
pub async fn init(cfg: &DbCfg) -> Result<DatabaseConnection, CrabError> { pub async fn init(cfg: &DbCfg) -> Result<DatabaseConnection, CrabError> {
let db = Database::connect(&cfg.url).await?; let db = Database::connect(&cfg.url).await?;
Ok(db) Ok(db)
@ -352,6 +371,25 @@ fn random_id() -> String {
base64ct::Base64UrlUnpadded::encode_string(&bytes) base64ct::Base64UrlUnpadded::encode_string(&bytes)
} }
/// Generate 8-character base-20 user code in format XXXX-XXXX
/// Alphabet: BCDFGHJKLMNPQRSTVWXZ (consonants only, no ambiguous chars)
/// Entropy: 20^8 = ~43 bits
fn generate_user_code() -> String {
const ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
let mut rng = rand::thread_rng();
let mut code = String::with_capacity(9);
for i in 0..8 {
if i == 4 {
code.push('-');
}
let idx = rng.gen_range(0..ALPHABET.len());
code.push(ALPHABET[idx] as char);
}
code
}
// User management functions // User management functions
pub async fn create_user( pub async fn create_user(
@ -1186,6 +1224,305 @@ pub async fn revoke_consents_for_client(
Ok(()) Ok(())
} }
// Device Authorization Grant (RFC 8628) functions
/// Create a new device authorization code
pub async fn create_device_code(
db: &DatabaseConnection,
client_id: &str,
client_name: Option<String>,
scope: &str,
device_info: Option<String>,
) -> Result<DeviceCode, CrabError> {
let device_code = random_id();
let user_code = generate_user_code();
let now = Utc::now().timestamp();
let expires_at = now + 1800; // 30 minutes
let device_code_model = entities::device_code::ActiveModel {
device_code: Set(device_code.clone()),
user_code: Set(user_code.clone()),
client_id: Set(client_id.to_string()),
client_name: Set(client_name.clone()),
scope: Set(scope.to_string()),
device_info: Set(device_info.clone()),
created_at: Set(now),
expires_at: Set(expires_at),
last_poll_at: Set(None),
interval: Set(5),
status: Set("pending".to_string()),
subject: Set(None),
auth_time: Set(None),
amr: Set(None),
acr: Set(None),
};
device_code_model.insert(db).await?;
Ok(DeviceCode {
device_code,
user_code,
client_id: client_id.to_string(),
client_name,
scope: scope.to_string(),
device_info,
created_at: now,
expires_at,
last_poll_at: None,
interval: 5,
status: "pending".to_string(),
subject: None,
auth_time: None,
amr: None,
acr: None,
})
}
/// Get device code by device_code
pub async fn get_device_code(
db: &DatabaseConnection,
device_code: &str,
) -> Result<Option<DeviceCode>, CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
let result = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?;
match result {
Some(dc) => {
// Check if expired
if dc.expires_at < now {
return Ok(None);
}
Ok(Some(DeviceCode {
device_code: dc.device_code,
user_code: dc.user_code,
client_id: dc.client_id,
client_name: dc.client_name,
scope: dc.scope,
device_info: dc.device_info,
created_at: dc.created_at,
expires_at: dc.expires_at,
last_poll_at: dc.last_poll_at,
interval: dc.interval,
status: dc.status,
subject: dc.subject,
auth_time: dc.auth_time,
amr: dc.amr,
acr: dc.acr,
}))
}
None => Ok(None),
}
}
/// Get device code by user_code
pub async fn get_device_code_by_user_code(
db: &DatabaseConnection,
user_code: &str,
) -> Result<Option<DeviceCode>, CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
let result = Entity::find()
.filter(Column::UserCode.eq(user_code))
.one(db)
.await?;
match result {
Some(dc) => {
// Check if expired
if dc.expires_at < now {
return Ok(None);
}
Ok(Some(DeviceCode {
device_code: dc.device_code,
user_code: dc.user_code,
client_id: dc.client_id,
client_name: dc.client_name,
scope: dc.scope,
device_info: dc.device_info,
created_at: dc.created_at,
expires_at: dc.expires_at,
last_poll_at: dc.last_poll_at,
interval: dc.interval,
status: dc.status,
subject: dc.subject,
auth_time: dc.auth_time,
amr: dc.amr,
acr: dc.acr,
}))
}
None => Ok(None),
}
}
/// Update device code polling metadata
pub async fn update_device_code_poll(
db: &DatabaseConnection,
device_code: &str,
) -> Result<(), CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
if let Some(dc) = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?
{
let mut active: entities::device_code::ActiveModel = dc.into();
active.last_poll_at = Set(Some(now));
active.update(db).await?;
}
Ok(())
}
/// Approve device code with user authentication context
pub async fn approve_device_code(
db: &DatabaseConnection,
device_code: &str,
subject: &str,
auth_time: i64,
amr: Option<String>,
acr: Option<String>,
) -> Result<(), CrabError> {
use entities::device_code::{Column, Entity};
if let Some(dc) = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?
{
let mut active: entities::device_code::ActiveModel = dc.into();
active.status = Set("approved".to_string());
active.subject = Set(Some(subject.to_string()));
active.auth_time = Set(Some(auth_time));
active.amr = Set(amr);
active.acr = Set(acr);
active.update(db).await?;
}
Ok(())
}
/// Deny device code
pub async fn deny_device_code(
db: &DatabaseConnection,
device_code: &str,
) -> Result<(), CrabError> {
use entities::device_code::{Column, Entity};
if let Some(dc) = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?
{
let mut active: entities::device_code::ActiveModel = dc.into();
active.status = Set("denied".to_string());
active.update(db).await?;
}
Ok(())
}
/// Consume device code (mark as used) and return its data
pub async fn consume_device_code(
db: &DatabaseConnection,
device_code: &str,
) -> Result<Option<DeviceCode>, CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
if let Some(dc) = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?
{
// Check if expired
if dc.expires_at < now {
return Ok(None);
}
// Check if status is approved
if dc.status != "approved" {
return Ok(None);
}
// Mark as consumed
let mut active: entities::device_code::ActiveModel = dc.clone().into();
active.status = Set("consumed".to_string());
active.update(db).await?;
// Return the device code data
Ok(Some(DeviceCode {
device_code: dc.device_code,
user_code: dc.user_code,
client_id: dc.client_id,
client_name: dc.client_name,
scope: dc.scope,
device_info: dc.device_info,
created_at: dc.created_at,
expires_at: dc.expires_at,
last_poll_at: dc.last_poll_at,
interval: dc.interval,
status: "consumed".to_string(),
subject: dc.subject,
auth_time: dc.auth_time,
amr: dc.amr,
acr: dc.acr,
}))
} else {
Ok(None)
}
}
/// Increment device code polling interval by 5 seconds (for slow_down)
pub async fn increment_device_code_interval(
db: &DatabaseConnection,
device_code: &str,
) -> Result<(), CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
if let Some(dc) = Entity::find()
.filter(Column::DeviceCode.eq(device_code))
.one(db)
.await?
{
let mut active: entities::device_code::ActiveModel = dc.into();
active.interval = Set(active.interval.clone().unwrap() + 5);
active.last_poll_at = Set(Some(now));
active.update(db).await?;
}
Ok(())
}
/// Cleanup expired device codes
pub async fn cleanup_expired_device_codes(db: &DatabaseConnection) -> Result<u64, CrabError> {
use entities::device_code::{Column, Entity};
let now = Utc::now().timestamp();
let result = Entity::delete_many()
.filter(Column::ExpiresAt.lt(now))
.exec(db)
.await?;
Ok(result.rows_affected)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -26,6 +26,7 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use urlencoding;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -122,6 +123,11 @@ pub async fn serve(
.route("/token", post(token)) .route("/token", post(token))
.route("/revoke", post(token_revoke)) .route("/revoke", post(token_revoke))
.route("/userinfo", get(userinfo)) .route("/userinfo", get(userinfo))
// Device Authorization Grant (RFC 8628) endpoints
.route("/device_authorization", post(device_authorization))
.route("/device", get(device_page))
.route("/device/verify", post(device_verify))
.route("/device/consent", post(device_consent))
// WebAuthn / Passkey endpoints // WebAuthn / Passkey endpoints
.route("/webauthn/register/start", post(passkey_register_start)) .route("/webauthn/register/start", post(passkey_register_start))
.route("/webauthn/register/finish", post(passkey_register_finish)) .route("/webauthn/register/finish", post(passkey_register_finish))
@ -200,6 +206,7 @@ async fn discovery(State(state): State<AppState>) -> impl IntoResponse {
"issuer": issuer, "issuer": issuer,
"authorization_endpoint": format!("{}/authorize", issuer), "authorization_endpoint": format!("{}/authorize", issuer),
"token_endpoint": format!("{}/token", issuer), "token_endpoint": format!("{}/token", issuer),
"device_authorization_endpoint": format!("{}/device_authorization", issuer),
"jwks_uri": format!("{}/.well-known/jwks.json", issuer), "jwks_uri": format!("{}/.well-known/jwks.json", issuer),
"registration_endpoint": format!("{}/connect/register", issuer), "registration_endpoint": format!("{}/connect/register", issuer),
"userinfo_endpoint": format!("{}/userinfo", issuer), "userinfo_endpoint": format!("{}/userinfo", issuer),
@ -208,7 +215,7 @@ async fn discovery(State(state): State<AppState>) -> impl IntoResponse {
"subject_types_supported": ["public"], "subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [state.settings.keys.alg], "id_token_signing_alg_values_supported": [state.settings.keys.alg],
// Additional recommended metadata for better interoperability // Additional recommended metadata for better interoperability
"grant_types_supported": ["authorization_code", "refresh_token", "implicit"], "grant_types_supported": ["authorization_code", "refresh_token", "implicit", "urn:ietf:params:oauth:grant-type:device_code"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"], "code_challenge_methods_supported": ["S256"],
"claims_supported": [ "claims_supported": [
@ -1114,6 +1121,7 @@ struct TokenRequest {
client_secret: Option<String>, client_secret: Option<String>,
code_verifier: Option<String>, code_verifier: Option<String>,
refresh_token: Option<String>, refresh_token: Option<String>,
device_code: Option<String>, // For device_code grant
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -1156,6 +1164,9 @@ async fn token(
match req.grant_type.as_str() { match req.grant_type.as_str() {
"authorization_code" => handle_authorization_code_grant(state, headers, req).await, "authorization_code" => handle_authorization_code_grant(state, headers, req).await,
"refresh_token" => handle_refresh_token_grant(state, headers, req).await, "refresh_token" => handle_refresh_token_grant(state, headers, req).await,
"urn:ietf:params:oauth:grant-type:device_code" => {
handle_device_code_grant(state, headers, req).await
}
_ => ( _ => (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(json!({"error":"unsupported_grant_type"})), Json(json!({"error":"unsupported_grant_type"})),
@ -1591,6 +1602,247 @@ async fn handle_refresh_token_grant(
) )
} }
async fn handle_device_code_grant(
state: AppState,
_headers: HeaderMap,
req: TokenRequest,
) -> Response {
// Require device_code parameter
let device_code_str = match req.device_code {
Some(dc) => dc,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_request",
"error_description": "device_code required"
})),
)
.into_response()
}
};
// Require client_id (device flow uses public clients, so no secret required)
let client_id = match req.client_id {
Some(cid) => cid,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_request",
"error_description": "client_id required"
})),
)
.into_response()
}
};
// Get device code
let device_code = match storage::get_device_code(&state.db, &device_code_str).await {
Ok(Some(dc)) => dc,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_grant",
"error_description": "device_code not found or expired"
})),
)
.into_response()
}
};
// Verify device_code bound to same client_id
if device_code.client_id != client_id {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_grant",
"error_description": "device_code not bound to this client"
})),
)
.into_response();
}
// Rate limiting: check if polling too fast
if let Some(last_poll) = device_code.last_poll_at {
let now = chrono::Utc::now().timestamp();
let elapsed = now - last_poll;
if elapsed < device_code.interval {
// Polling too fast - increment interval and return slow_down
let _ = storage::increment_device_code_interval(&state.db, &device_code_str).await;
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "slow_down",
"error_description": "Polling too frequently"
})),
)
.into_response();
}
}
// Update last_poll_at
let _ = storage::update_device_code_poll(&state.db, &device_code_str).await;
// Check status
match device_code.status.as_str() {
"pending" => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "authorization_pending",
"error_description": "User has not yet authorized the device"
})),
)
.into_response()
}
"denied" => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "access_denied",
"error_description": "User denied the authorization request"
})),
)
.into_response()
}
"consumed" => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_grant",
"error_description": "device_code already used"
})),
)
.into_response()
}
"approved" => {
// Continue to token issuance
}
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"error_description": "Unknown device_code status"
})),
)
.into_response()
}
}
// Consume device code
let consumed_device_code = match storage::consume_device_code(&state.db, &device_code_str).await
{
Ok(Some(dc)) => dc,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_grant",
"error_description": "Failed to consume device_code"
})),
)
.into_response()
}
};
let subject = consumed_device_code.subject.unwrap();
// Issue access token
let access = match storage::issue_access_token(
&state.db,
&client_id,
&subject,
&consumed_device_code.scope,
3600,
)
.await
{
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"details": e.to_string()
})),
)
.into_response()
}
};
// Build ID Token with profile claims
let id_token = match build_id_token(
&state,
&client_id,
&subject,
None, // No nonce for device flow
consumed_device_code.auth_time,
Some(&access.token),
consumed_device_code.amr.as_deref(),
consumed_device_code.acr.as_deref(),
)
.await
{
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"details": e.to_string()
})),
)
.into_response()
}
};
// Issue refresh token if offline_access scope requested
let refresh_token_opt = if consumed_device_code
.scope
.split_whitespace()
.any(|s| s == "offline_access")
{
match storage::issue_refresh_token(
&state.db,
&client_id,
&subject,
&consumed_device_code.scope,
2592000, // 30 days
None, // No parent token for device flow
)
.await
{
Ok(rt) => Some(rt.token),
Err(_) => None,
}
} else {
None
};
let resp = TokenResponse {
access_token: access.token,
token_type: "bearer".into(),
expires_in: 3600,
id_token: Some(id_token),
refresh_token: refresh_token_opt,
};
// Add Cache-Control: no-store as required by OAuth 2.0 and OIDC specs
json_with_headers(
StatusCode::OK,
serde_json::to_value(resp).unwrap(),
&[
("cache-control", "no-store".to_string()),
("pragma", "no-cache".to_string()),
],
)
}
fn authenticate_client( fn authenticate_client(
headers: &HeaderMap, headers: &HeaderMap,
req: &TokenRequest, req: &TokenRequest,
@ -2948,3 +3200,426 @@ async fn update_passkey_handler(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
// ========================================
// Device Authorization Grant (RFC 8628)
// ========================================
#[derive(Deserialize)]
struct DeviceAuthorizationRequest {
client_id: Option<String>,
client_name: Option<String>,
scope: Option<String>,
}
#[derive(Serialize)]
struct DeviceAuthorizationResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: String,
expires_in: i64,
interval: i64,
#[serde(skip_serializing_if = "Option::is_none")]
client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
client_secret: Option<String>,
}
/// POST /device_authorization - RFC 8628 Device Authorization Endpoint
async fn device_authorization(
State(state): State<AppState>,
headers: HeaderMap,
Form(req): Form<DeviceAuthorizationRequest>,
) -> Result<Json<DeviceAuthorizationResponse>, (StatusCode, Json<serde_json::Value>)> {
// Extract device info
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let ip_address = headers
.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.split(',').next())
.unwrap_or("Unknown")
.to_string();
let device_info = serde_json::json!({
"ip_address": ip_address,
"user_agent": user_agent,
})
.to_string();
// Determine client_id (auto-register if not provided)
let (client_id, client_secret, client_name, auto_registered) = if let Some(cid) = req.client_id
{
// Validate existing client
let client = storage::get_client(&state.db, &cid).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"error_description": format!("Database error: {}", e)
})),
)
})?;
if client.is_none() {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "invalid_client",
"error_description": "Client not found"
})),
));
}
let client = client.unwrap();
(client.client_id, client.client_secret, client.client_name, false)
} else {
// Auto-register new client
let new_client_name = req.client_name.unwrap_or_else(|| "Auto-registered Device".to_string());
let new_client = storage::NewClient {
client_name: Some(new_client_name.clone()),
redirect_uris: vec![], // Device flow doesn't use redirect URIs
};
let client = storage::create_client(&state.db, new_client)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"error_description": format!("Failed to create client: {}", e)
})),
)
})?;
(client.client_id, client.client_secret, Some(new_client_name), true)
};
// Validate scope (must include "openid" for OIDC)
let scope = req.scope.unwrap_or_else(|| "openid".to_string());
if !scope.split_whitespace().any(|s| s == "openid") {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_scope",
"error_description": "Scope must include 'openid'"
})),
));
}
// Create device code
let device_code = storage::create_device_code(
&state.db,
&client_id,
client_name,
&scope,
Some(device_info),
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "server_error",
"error_description": format!("Failed to create device code: {}", e)
})),
)
})?;
// Build URIs
let issuer = state.settings.issuer();
let verification_uri = format!("{}/device", issuer);
let verification_uri_complete = format!("{}/device?user_code={}", issuer, device_code.user_code);
Ok(Json(DeviceAuthorizationResponse {
device_code: device_code.device_code,
user_code: device_code.user_code,
verification_uri,
verification_uri_complete,
expires_in: 1800,
interval: 5,
client_id: if auto_registered { Some(client_id) } else { None },
client_secret: if auto_registered { Some(client_secret) } else { None },
}))
}
#[derive(Deserialize)]
struct DevicePageQuery {
user_code: Option<String>,
}
/// GET /device - Device verification page
async fn device_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<DevicePageQuery>,
) -> Result<Html<String>, Redirect> {
// Check if user has session
let session_cookie = SessionCookie::from_headers(&headers);
if let Some(cookie) = session_cookie {
if let Ok(Some(_session)) = storage::get_session(&state.db, &cookie.session_id).await {
// User is authenticated, show form
let prefilled_code = query.user_code.as_deref().unwrap_or("");
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Verification</title>
<style>
body {{ font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }}
.container {{ background: #f5f5f5; padding: 30px; border-radius: 8px; }}
h1 {{ margin-top: 0; }}
input {{ font-size: 18px; padding: 10px; width: 100%; box-sizing: border-box; margin: 10px 0; text-transform: uppercase; }}
button {{ background: #007bff; color: white; border: none; padding: 12px 24px; font-size: 16px; border-radius: 4px; cursor: pointer; }}
button:hover {{ background: #0056b3; }}
.instructions {{ background: white; padding: 15px; border-left: 4px solid #007bff; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="container">
<h1>Device Verification</h1>
<div class="instructions">
<p>Enter the code shown on your device to authorize access.</p>
<p>Format: <strong>XXXX-XXXX</strong> (8 characters)</p>
</div>
<form method="POST" action="/device/verify">
<input type="text" name="user_code" placeholder="Enter code (e.g., WDJB-MJHT)" value="{}" maxlength="9" pattern="[A-Z]{{4}}-[A-Z]{{4}}" required autofocus>
<button type="submit">Verify Device</button>
</form>
</div>
</body>
</html>"#,
prefilled_code
);
return Ok(Html(html));
}
}
// No session, redirect to login
let return_to = if let Some(code) = query.user_code {
format!("/login?return_to={}", urlencoding::encode(&format!("/device?user_code={}", code)))
} else {
"/login?return_to=/device".to_string()
};
Err(Redirect::to(&return_to))
}
#[derive(Deserialize)]
struct DeviceVerifyRequest {
user_code: String,
}
/// POST /device/verify - Verify user code and show consent page
async fn device_verify(
State(state): State<AppState>,
headers: HeaderMap,
Form(req): Form<DeviceVerifyRequest>,
) -> Result<Html<String>, (StatusCode, String)> {
// Require authenticated session
let session_cookie = SessionCookie::from_headers(&headers)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let _session = storage::get_session(&state.db, &session_cookie.session_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Session not found".to_string()))?;
// Lookup device code by user_code
let device_code = storage::get_device_code_by_user_code(&state.db, &req.user_code.to_uppercase())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Device code not found or expired".to_string()))?;
// Parse device_info
let device_info: serde_json::Value = serde_json::from_str(device_code.device_info.as_deref().unwrap_or("{}"))
.unwrap_or(json!({}));
let ip_address = device_info["ip_address"].as_str().unwrap_or("Unknown");
let user_agent = device_info["user_agent"].as_str().unwrap_or("Unknown");
// Render consent page
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize Device</title>
<style>
body {{ font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }}
.container {{ background: #f5f5f5; padding: 30px; border-radius: 8px; }}
h1 {{ margin-top: 0; }}
.device-info {{ background: white; padding: 15px; border-radius: 4px; margin: 20px 0; }}
.device-info dt {{ font-weight: bold; margin-top: 10px; }}
.device-info dd {{ margin-left: 0; color: #555; }}
.code-display {{ background: #007bff; color: white; padding: 15px; text-align: center; font-size: 24px; font-family: monospace; border-radius: 4px; margin: 20px 0; letter-spacing: 2px; }}
.buttons {{ display: flex; gap: 10px; margin-top: 20px; }}
button {{ flex: 1; padding: 12px; font-size: 16px; border: none; border-radius: 4px; cursor: pointer; }}
.approve {{ background: #28a745; color: white; }}
.approve:hover {{ background: #218838; }}
.deny {{ background: #dc3545; color: white; }}
.deny:hover {{ background: #c82333; }}
.warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="container">
<h1>Authorize Device</h1>
<div class="warning">
<strong>Verify this is your device!</strong> Only approve if you recognize the device information below.
</div>
<div class="code-display">{}</div>
<div class="device-info">
<dl>
<dt>Client:</dt>
<dd>{}</dd>
<dt>Requested Scopes:</dt>
<dd>{}</dd>
<dt>IP Address:</dt>
<dd>{}</dd>
<dt>User Agent:</dt>
<dd>{}</dd>
</dl>
</div>
<div class="buttons">
<form method="POST" action="/device/consent" style="flex: 1;">
<input type="hidden" name="user_code" value="{}">
<input type="hidden" name="approved" value="true">
<button type="submit" class="approve">Approve</button>
</form>
<form method="POST" action="/device/consent" style="flex: 1;">
<input type="hidden" name="user_code" value="{}">
<input type="hidden" name="approved" value="false">
<button type="submit" class="deny">Deny</button>
</form>
</div>
</div>
</body>
</html>"#,
device_code.user_code,
device_code.client_name.as_deref().unwrap_or("Unknown Application"),
device_code.scope,
ip_address,
user_agent,
device_code.user_code,
device_code.user_code
);
Ok(Html(html))
}
#[derive(Deserialize)]
struct DeviceConsentRequest {
user_code: String,
#[serde(default)]
approved: bool,
}
/// POST /device/consent - Handle device authorization approval/denial
async fn device_consent(
State(state): State<AppState>,
headers: HeaderMap,
Form(req): Form<DeviceConsentRequest>,
) -> Result<Html<String>, (StatusCode, String)> {
// Require authenticated session
let session_cookie = SessionCookie::from_headers(&headers)
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
let session = storage::get_session(&state.db, &session_cookie.session_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Session not found".to_string()))?;
// Lookup device code by user_code
let device_code = storage::get_device_code_by_user_code(&state.db, &req.user_code.to_uppercase())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Device code not found or expired".to_string()))?;
// TODO: Check 2FA requirements (admin-enforced, high-value scopes, max_age)
// For now, we'll skip 2FA checks and proceed directly
if req.approved {
// Approve the device code
storage::approve_device_code(
&state.db,
&device_code.device_code,
&session.subject,
session.auth_time,
session.amr,
session.acr,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Show success page
Ok(Html(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Approved</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { background: #d4edda; border: 1px solid #c3e6cb; padding: 30px; border-radius: 8px; text-align: center; }
h1 { color: #155724; margin-top: 0; }
p { color: #155724; font-size: 18px; }
.checkmark { font-size: 48px; color: #28a745; }
</style>
</head>
<body>
<div class="container">
<div class="checkmark"></div>
<h1>Device Approved</h1>
<p>You can now return to your device and continue.</p>
</div>
</body>
</html>"#
.to_string(),
))
} else {
// Deny the device code
storage::deny_device_code(&state.db, &device_code.device_code)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Show denial page
Ok(Html(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Denied</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.container { background: #f8d7da; border: 1px solid #f5c6cb; padding: 30px; border-radius: 8px; text-align: center; }
h1 { color: #721c24; margin-top: 0; }
p { color: #721c24; font-size: 18px; }
.cross { font-size: 48px; color: #dc3545; }
</style>
</head>
<body>
<div class="container">
<div class="cross"></div>
<h1>Device Access Denied</h1>
<p>The authorization request has been rejected.</p>
</div>
</body>
</html>"#
.to_string(),
))
}
}