Skip to main content

alibi_plugins/device_authorization/
grant.rs

1//! Application-owned issuance and redemption of durable device grants.
2use super::types::DeviceCodeRequest;
3use super::{
4    ACCESS_DENIED, AUTHORIZATION_PENDING, DEVICE_STATUS_APPROVED, DEVICE_STATUS_DENIED,
5    DEVICE_STATUS_PENDING, DeviceAuthorizationPlugin, EXPIRED_DEVICE_CODE, INVALID_DEVICE_CODE,
6    INVALID_DEVICE_CODE_STATUS, POLLING_TOO_FREQUENTLY, USER_NOT_FOUND, device_error_response,
7    no_store, validate_device_media,
8};
9use crate::helpers::callback_failure;
10use alibi_core::{
11    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, DeviceCode,
12    UpdateDeviceCode,
13};
14use async_trait::async_trait;
15use chrono::Utc;
16use serde_json::{Map, Value};
17use std::sync::Arc;
18
19#[derive(Debug)]
20pub enum DeviceGrantFailure {
21    OAuth {
22        status: u16,
23        error: String,
24        description: String,
25    },
26    Application(AuthError),
27}
28impl From<AuthError> for DeviceGrantFailure {
29    fn from(error: AuthError) -> Self {
30        Self::Application(error)
31    }
32}
33impl DeviceGrantFailure {
34    pub fn oauth(status: u16, error: impl Into<String>, description: impl Into<String>) -> Self {
35        Self::OAuth {
36            status,
37            error: error.into(),
38            description: description.into(),
39        }
40    }
41    pub fn into_response(self) -> AuthResult<AuthResponse> {
42        match self {
43            Self::OAuth {
44                status,
45                error,
46                description,
47            } => device_error_response(status, &error, &description),
48            Self::Application(error) => Err(callback_failure(error)),
49        }
50    }
51}
52#[derive(Debug, Clone)]
53pub struct DeviceGrantRecord {
54    pub device_code: DeviceCode,
55    pub fields: Map<String, Value>,
56}
57#[derive(Debug, Clone)]
58pub struct DeviceGrantAuthorization {
59    pub client_id: String,
60    pub user_id: Option<String>,
61    pub fields: Map<String, Value>,
62}
63
64/// Application protocol layered over the shared device-code lifecycle.
65#[async_trait]
66pub trait DeviceAuthorizationGrant: Send + Sync {
67    /// Required string request fields and their JSON schemas, including minLength.
68    fn request_schema_fields(&self) -> Map<String, Value> {
69        Map::new()
70    }
71    fn on_request_validation_error(&self, issues: &[String]) -> DeviceGrantFailure {
72        DeviceGrantFailure::oauth(400, "invalid_request", issues.join("; "))
73    }
74    async fn authorize_request(
75        &self,
76        request: &Map<String, Value>,
77        original: Option<&AuthRequest>,
78    ) -> Result<DeviceGrantAuthorization, DeviceGrantFailure>;
79    async fn assert_session_redemption(
80        &self,
81        _record: &DeviceGrantRecord,
82    ) -> Result<(), DeviceGrantFailure> {
83        Ok(())
84    }
85    async fn verification_context(
86        &self,
87        _record: &DeviceGrantRecord,
88    ) -> AuthResult<Map<String, Value>> {
89        Ok(Map::new())
90    }
91    fn device_code_schema_fields(&self) -> Vec<alibi_core::OpenApiField> {
92        Vec::new()
93    }
94    fn request_error_codes(&self) -> Vec<String> {
95        Vec::new()
96    }
97    fn request_openapi_responses(&self) -> Map<String, Value> {
98        Map::new()
99    }
100    fn verification_openapi_properties(&self) -> Map<String, Value> {
101        Map::new()
102    }
103}
104#[derive(Debug, Clone)]
105pub struct DeviceRedemptionAuthorization {
106    pub ownership: Map<String, Value>,
107    pub context: Value,
108}
109/// Preparation runs before the atomic consume; rejection leaves the grant usable.
110#[async_trait]
111pub trait DeviceRedemptionPolicy: Send + Sync {
112    async fn authorize(
113        &self,
114        record: &DeviceGrantRecord,
115    ) -> AuthResult<DeviceRedemptionAuthorization>;
116    async fn prepare(&self, record: &DeviceGrantRecord, authorization: &Value)
117    -> AuthResult<Value>;
118}
119pub struct DeviceRedemptionResult<S: AuthSchema> {
120    pub claimed_device_code: DeviceGrantRecord,
121    pub user: S::User,
122    pub authorization_context: Value,
123    pub redemption_context: Value,
124}
125
126/// Redeem one approved grant using a storage-side ownership predicate.
127pub async fn redeem_device_code<S: AuthSchema>(
128    ctx: &AuthContext<S>,
129    code: &str,
130    policy: &dyn DeviceRedemptionPolicy,
131) -> Result<DeviceRedemptionResult<S>, DeviceGrantFailure> {
132    let row = ctx
133        .database
134        .get_device_code_by_device_code(code)
135        .await?
136        .ok_or_else(|| DeviceGrantFailure::oauth(400, "invalid_grant", INVALID_DEVICE_CODE))?;
137    let record = DeviceGrantRecord {
138        fields: ctx.database.device_code_fields(&row.id).await?,
139        device_code: row,
140    };
141    let authorization = policy.authorize(&record).await?;
142    let row = &record.device_code;
143    let now = Utc::now();
144    if let (Some(last), Some(interval)) = (row.last_polled_at, row.polling_interval)
145        && interval != 0
146        && now.signed_duration_since(last).num_milliseconds() < interval
147    {
148        return Err(DeviceGrantFailure::oauth(
149            400,
150            "slow_down",
151            POLLING_TOO_FREQUENTLY,
152        ));
153    }
154    _ = ctx
155        .database
156        .update_device_code(
157            &row.id,
158            UpdateDeviceCode {
159                last_polled_at: Some(Some(now)),
160                ..Default::default()
161            },
162        )
163        .await?;
164    if row.expires_at < now {
165        ctx.database.delete_device_code(&row.id).await?;
166        return Err(DeviceGrantFailure::oauth(
167            400,
168            "expired_token",
169            EXPIRED_DEVICE_CODE,
170        ));
171    }
172    match row.status.as_str() {
173        DEVICE_STATUS_PENDING => {
174            return Err(DeviceGrantFailure::oauth(
175                400,
176                "authorization_pending",
177                AUTHORIZATION_PENDING,
178            ));
179        }
180        DEVICE_STATUS_DENIED => {
181            ctx.database.delete_device_code(&row.id).await?;
182            return Err(DeviceGrantFailure::oauth(
183                400,
184                "access_denied",
185                ACCESS_DENIED,
186            ));
187        }
188        DEVICE_STATUS_APPROVED if row.user_id.is_some() => {}
189        _ => {
190            return Err(DeviceGrantFailure::oauth(
191                500,
192                "server_error",
193                INVALID_DEVICE_CODE_STATUS,
194            ));
195        }
196    }
197    let redemption_context = policy.prepare(&record, &authorization.context).await?;
198    let user = ctx
199        .database
200        .get_user_by_id(
201            row.user_id
202                .as_deref()
203                .ok_or_else(|| DeviceGrantFailure::oauth(500, "server_error", USER_NOT_FOUND))?,
204        )
205        .await?
206        .ok_or_else(|| DeviceGrantFailure::oauth(500, "server_error", USER_NOT_FOUND))?;
207    let claimed = ctx
208        .database
209        .consume_device_code(&row.id, DEVICE_STATUS_APPROVED, &authorization.ownership)
210        .await?
211        .ok_or_else(|| DeviceGrantFailure::oauth(400, "invalid_grant", INVALID_DEVICE_CODE))?;
212    Ok(DeviceRedemptionResult {
213        claimed_device_code: DeviceGrantRecord {
214            device_code: claimed,
215            fields: record.fields,
216        },
217        user,
218        authorization_context: authorization.context,
219        redemption_context,
220    })
221}
222
223impl DeviceAuthorizationPlugin {
224    #[must_use]
225    pub fn grant(mut self, grant: impl DeviceAuthorizationGrant + 'static) -> Self {
226        self.config.grant = Some(Arc::new(grant));
227        self
228    }
229    pub(super) async fn handle_application_issuance(
230        &self,
231        req: &AuthRequest,
232        ctx: &AuthContext<impl AuthSchema>,
233        grant: &dyn DeviceAuthorizationGrant,
234    ) -> AuthResult<AuthResponse> {
235        let is_form = match validate_device_media(req, true) {
236            Ok(form) => form,
237            Err(response) => return Ok(response),
238        };
239        let body: Value = if is_form {
240            Value::Object(
241                url::form_urlencoded::parse(req.body.as_deref().unwrap_or_default())
242                    .map(|(key, value)| (key.into_owned(), Value::String(value.into_owned())))
243                    .collect(),
244            )
245        } else {
246            req.body_as_json()?
247        };
248        self.application_issuance_body(body, Some(req), ctx, grant)
249            .await
250    }
251
252    pub(super) async fn application_issuance_body(
253        &self,
254        body: Value,
255        original: Option<&AuthRequest>,
256        ctx: &AuthContext<impl AuthSchema>,
257        grant: &dyn DeviceAuthorizationGrant,
258    ) -> AuthResult<AuthResponse> {
259        let mut request = body.as_object().cloned().unwrap_or_default();
260        let fields = grant.request_schema_fields();
261        let mut issues = Vec::new();
262        for (name, schema) in &fields {
263            let value = request.get(name).and_then(Value::as_str);
264            let minimum = schema.get("minLength").and_then(Value::as_u64).unwrap_or(0);
265            if value.is_none_or(|value| {
266                value.encode_utf16().count() < usize::try_from(minimum).unwrap_or(usize::MAX)
267            }) {
268                issues.push(name.clone());
269            }
270        }
271        for field in ["scope", "client_id", "user_id"] {
272            if request.get(field).is_some_and(|value| !value.is_string()) {
273                issues.push(field.into());
274            }
275        }
276        if !issues.is_empty() {
277            return grant.on_request_validation_error(&issues).into_response();
278        }
279        request.retain(|name, _| {
280            ["scope", "client_id", "user_id"].contains(&name.as_str()) || fields.contains_key(name)
281        });
282        let authorized = match grant.authorize_request(&request, original).await {
283            Ok(value) => value,
284            Err(error) => {
285                return error.into_response().map(no_store);
286            }
287        };
288        self.issue_device_code_with_fields(
289            DeviceCodeRequest {
290                client_id: authorized.client_id,
291                user_id: authorized.user_id,
292                scope: request
293                    .get("scope")
294                    .and_then(Value::as_str)
295                    .map(str::to_owned),
296            },
297            authorized.fields,
298            ctx,
299        )
300        .await
301        .map(no_store)
302    }
303    pub(super) fn grant_openapi(
304        &self,
305        mut metadata: alibi_core::PluginOpenApiMetadata,
306    ) -> alibi_core::PluginOpenApiMetadata {
307        let Some(grant) = &self.config.grant else {
308            return metadata;
309        };
310        metadata.models.push(alibi_core::OpenApiModel::new(
311            "DeviceCode",
312            grant.device_code_schema_fields(),
313        ));
314        let mut properties = grant.request_schema_fields();
315        _ = properties.insert(
316            "scope".into(),
317            serde_json::json!({"type":"string","description":"Space-separated list of scopes"}),
318        );
319        _ = properties.insert("client_id".into(), serde_json::json!({"type":"string"}));
320        _ = properties.insert("user_id".into(), serde_json::json!({"type":"string","description":"The user ID to which the device code should be pre-bound."}));
321        let required = grant
322            .request_schema_fields()
323            .keys()
324            .cloned()
325            .collect::<Vec<_>>();
326        for (_, path, endpoint) in &mut metadata.endpoints {
327            if path == "/device/code" {
328                endpoint.request_body = Some(
329                    serde_json::json!({"required":true,"content":{"application/json":{"schema":{"type":"object","properties":properties,"required":required}}}}),
330                );
331                if let Some(codes) = endpoint
332                    .responses
333                    .get_mut("400")
334                    .and_then(|response| {
335                        response
336                            .pointer_mut("/content/application~1json/schema/properties/error/enum")
337                    })
338                    .and_then(Value::as_array_mut)
339                {
340                    codes.extend(grant.request_error_codes().into_iter().map(Value::String));
341                }
342                for (status, response) in grant.request_openapi_responses() {
343                    _ = endpoint.responses.insert(status, response);
344                }
345            }
346            if path == "/device" {
347                let response=endpoint.responses.entry("200".into()).or_insert_with(||serde_json::json!({"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}));
348                if let Some(properties) = response
349                    .pointer_mut("/content/application~1json/schema/properties")
350                    .and_then(Value::as_object_mut)
351                {
352                    properties.extend(grant.verification_openapi_properties());
353                }
354            }
355        }
356        metadata
357    }
358}