Skip to main content

alibi_plugins/device_authorization/
endpoint.rs

1//! Public device issuance without manufacturing an HTTP request.
2pub use super::types::{
3    DeviceActionResponse, DeviceCodeRequest, DeviceCodeResponse, DeviceTokenResponse,
4};
5use super::{DeviceAuthorizationPlugin, DeviceRequestKind, no_store, validate_device_json_body};
6use alibi_core::endpoint::{
7    EndpointCall, EndpointDefinition, EndpointInput, EndpointResponse, ServerEndpoint,
8};
9use alibi_core::utils::json::JsValue;
10use alibi_core::{AuthContext, AuthResult, AuthSchema, HttpMethod};
11
12pub(super) fn definitions() -> Vec<EndpointDefinition> {
13    vec![
14        crate::endpoint::definition(
15            "deviceCode",
16            "deviceCode",
17            Some("/device/code"),
18            HttpMethod::Post,
19        ),
20        crate::endpoint::definition(
21            "deviceVerify",
22            "deviceVerify",
23            Some("/device"),
24            HttpMethod::Get,
25        ),
26        crate::endpoint::definition(
27            "deviceApprove",
28            "deviceApprove",
29            Some("/device/approve"),
30            HttpMethod::Post,
31        ),
32        crate::endpoint::definition(
33            "deviceDeny",
34            "deviceDeny",
35            Some("/device/deny"),
36            HttpMethod::Post,
37        ),
38        crate::endpoint::definition(
39            "deviceToken",
40            "deviceToken",
41            Some("/device/token"),
42            HttpMethod::Post,
43        ),
44    ]
45}
46
47pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
48    let body = if matches!(call.operation_id(), "deviceApprove" | "deviceDeny") {
49        let body = crate::endpoint::validate_fields(
50            call.body(),
51            "body",
52            &[crate::authentication_helpers::JsonField::string(
53                "userCode", true,
54            )],
55        )?;
56        if call.headers().is_none() {
57            return Err(crate::endpoint::validation("Headers is required"));
58        }
59        Some(body)
60    } else if call.operation_id() == "deviceToken" {
61        Some(validate_token_body(call.body())?)
62    } else {
63        call.body().cloned()
64    };
65    Ok(EndpointInput {
66        body,
67        query: if call.operation_id() == "deviceVerify" {
68            Some(crate::endpoint::validate_fields(
69                call.query(),
70                "query",
71                &[crate::authentication_helpers::JsonField::string(
72                    "user_code",
73                    true,
74                )],
75            )?)
76        } else {
77            call.query().cloned()
78        },
79    })
80}
81
82fn validate_token_body(body: Option<&JsValue>) -> AuthResult<JsValue> {
83    use crate::authentication_helpers::{JsonFieldKind, field_issue, json_type};
84    let Some(object) = body.and_then(JsValue::as_object) else {
85        return Err(crate::endpoint::validation(format!(
86            "[body] Invalid input: expected object, received {}",
87            json_type(body)
88        )));
89    };
90    let mut issues = Vec::new();
91    if object.get("grant_type").and_then(JsValue::as_str) != Some(super::DEVICE_GRANT_TYPE) {
92        issues.push(format!(
93            "[body.grant_type] Invalid input: expected \"{}\"",
94            super::DEVICE_GRANT_TYPE
95        ));
96    }
97    for field in ["device_code", "client_id"] {
98        let value = object.get(field);
99        if let Some(issue) = field_issue(JsonFieldKind::String, value, json_type(value)) {
100            issues.push(format!("[body.{field}] {issue}"));
101        }
102    }
103    if !issues.is_empty() {
104        return Err(crate::endpoint::validation(issues.join("; ")));
105    }
106    Ok(JsValue::Object(
107        object
108            .iter()
109            .filter(|(name, _)| ["grant_type", "device_code", "client_id"].contains(&name.as_str()))
110            .map(|(name, value)| (name.clone(), value.clone()))
111            .collect(),
112    ))
113}
114
115impl DeviceAuthorizationPlugin {
116    /// Redeem an approved device proof for its owner's bearer session.
117    #[must_use]
118    pub fn device_token_endpoint(
119        device_code: impl Into<String>,
120        client_id: impl Into<String>,
121    ) -> ServerEndpoint<DeviceTokenResponse> {
122        ServerEndpoint::new("device-authorization", "deviceToken").with_body_value(JsValue::Object(
123            [
124                (
125                    "grant_type".into(),
126                    JsValue::String(super::DEVICE_GRANT_TYPE.into()),
127                ),
128                ("device_code".into(), JsValue::String(device_code.into())),
129                ("client_id".into(), JsValue::String(client_id.into())),
130            ]
131            .into_iter()
132            .collect(),
133        ))
134    }
135
136    pub(super) async fn token_endpoint<S: AuthSchema>(
137        &self,
138        call: &EndpointCall,
139        ctx: &AuthContext<S>,
140    ) -> AuthResult<EndpointResponse> {
141        call.set_response_header("Cache-Control", "no-store");
142        call.set_response_header("Pragma", "no-cache");
143        endpoint_response(no_store(
144            self.redeem_device_token(call.body_as()?, call, ctx).await?,
145        ))
146    }
147
148    /// Approve a pending device grant previously claimed by the caller's session.
149    #[must_use]
150    pub fn device_approve_endpoint(
151        user_code: impl Into<String>,
152    ) -> ServerEndpoint<DeviceActionResponse> {
153        ServerEndpoint::new("device-authorization", "deviceApprove").with_body_value(
154            JsValue::Object(
155                [("userCode".into(), JsValue::String(user_code.into()))]
156                    .into_iter()
157                    .collect(),
158            ),
159        )
160    }
161
162    /// Deny a pending device grant previously claimed by the caller's session.
163    #[must_use]
164    pub fn device_deny_endpoint(
165        user_code: impl Into<String>,
166    ) -> ServerEndpoint<DeviceActionResponse> {
167        ServerEndpoint::new("device-authorization", "deviceDeny").with_body_value(JsValue::Object(
168            [("userCode".into(), JsValue::String(user_code.into()))]
169                .into_iter()
170                .collect(),
171        ))
172    }
173
174    pub(super) async fn decision_endpoint<S: AuthSchema>(
175        &self,
176        call: &EndpointCall,
177        ctx: &AuthContext<S>,
178    ) -> AuthResult<EndpointResponse> {
179        let decision = if call.operation_id() == "deviceDeny" {
180            super::DeviceDecision::Deny
181        } else {
182            super::DeviceDecision::Approve
183        };
184        endpoint_response(
185            self.decision_core(call, ctx, decision, call.body_as()?)
186                .await?,
187        )
188    }
189
190    /// Look up a device code and claim a pending proof under the caller's session.
191    #[must_use]
192    pub fn device_verify_endpoint(
193        user_code: impl Into<String>,
194    ) -> ServerEndpoint<serde_json::Value> {
195        ServerEndpoint::new("device-authorization", "deviceVerify").with_query_value(
196            JsValue::Object(
197                [("user_code".into(), JsValue::String(user_code.into()))]
198                    .into_iter()
199                    .collect(),
200            ),
201        )
202    }
203
204    pub(super) async fn verify_endpoint<S: AuthSchema>(
205        &self,
206        call: &EndpointCall,
207        ctx: &AuthContext<S>,
208    ) -> AuthResult<EndpointResponse> {
209        #[derive(serde::Deserialize)]
210        struct Query {
211            user_code: String,
212        }
213        let query: Query = call.query_as()?;
214        let output = self.verify_core(call, ctx, query.user_code).await?;
215        endpoint_response(output)
216    }
217
218    /// Issue a device proof through the application's public server dispatcher.
219    /// # Errors
220    /// Returns an input serialization error.
221    pub fn device_code_endpoint(
222        request: &DeviceCodeRequest,
223    ) -> AuthResult<ServerEndpoint<DeviceCodeResponse>> {
224        ServerEndpoint::new("device-authorization", "deviceCode").with_body(request)
225    }
226
227    pub(super) async fn issue_endpoint<S: AuthSchema>(
228        &self,
229        call: &EndpointCall,
230        ctx: &AuthContext<S>,
231    ) -> AuthResult<EndpointResponse> {
232        let body: serde_json::Value = call
233            .body()
234            .map_or_else(|| Ok(serde_json::Value::Null), serde_json::to_value)?;
235        let output = if let Some(grant) = &self.config.grant {
236            self.application_issuance_body(body, call.request(), ctx, grant.as_ref())
237                .await?
238        } else {
239            match validate_device_json_body(&body, DeviceRequestKind::Issuance) {
240                Ok(()) => {
241                    call.set_response_header("Cache-Control", "no-store");
242                    call.set_response_header("Pragma", "no-cache");
243                    no_store(
244                        self.issue_device_code(serde_json::from_value(body)?, ctx)
245                            .await?,
246                    )
247                }
248                Err(output) => output,
249            }
250        };
251        endpoint_response(output)
252    }
253}
254
255fn endpoint_response(output: alibi_core::AuthResponse) -> AuthResult<EndpointResponse> {
256    let value = alibi_core::utils::json::from_slice::<JsValue>(&output.body)?;
257    let mut response = if output.status >= 400 {
258        EndpointResponse::error(crate::endpoint::error_response(output.clone()))
259            .with_error_body(value)
260    } else {
261        EndpointResponse::value(value)
262    };
263    response.set_status(Some(output.status));
264    response.merge_headers(output.headers);
265    Ok(response)
266}