Skip to main content

Crate cratefield_module_device_auth

Crate cratefield_module_device_auth 

Source
Expand description

The OAuth 2.0 device authorization grant (RFC 8628) as a harness module (issue #587): a client with no browser and no keyboard shows a short code, a signed-in person approves it in a browser, and the venture’s own issuer mints the credential.

The module owns the three things RFC 8628 leaves to the server — the code pair, the polling state machine and the approval page — and delegates the two things only a venture can answer, each behind a builder-set hook:

  • Approver answers who is asking to approve: a subject, or a redirect to sign in. CallerApprover is the stock implementation over the Auth port.
  • Issuer answers what credential does this client get: it is called exactly once, when a poll wins the consume, and returns the JSON body the client receives.

See docs/DEVICE-AUTH.md for the flow, the endpoints and the worked example; the client side of the flow lives in cratefield_oauth_client::device.

§cratefield-module-device-auth

The OAuth 2.0 device authorization grant (RFC 8628) for a Cratefield venture: the flow for a client that has no browser and no keyboard — a CLI, a TV app, an appliance. It shows a short code, a signed-in person approves it in a browser, and the venture mints the credential.

Most OAuth flows hand the browser a redirect. This one hands the client a pair of codes and a URL, because there is no browser to redirect:

  1. The client posts to /v1/device-auth/code and gets back a device_code (long, secret, for the client), a user_code (short, for the person) and the URL to visit.
  2. The client shows the person the code and the URL, then starts polling /v1/device-auth/token.
  3. The person opens the URL, signs in if they have not, types the code, and presses Approve.
  4. The next poll that has waited out its interval is answered with the credential the venture’s issuer minted.

§Composition

use cratefield_core::{RandomBytes, RandomError};
use cratefield_module_device_auth::{DeviceAuth, DeviceClient, IssueRequest, Issuer, IssuerError};

// The two hooks a venture supplies: what a credential is, and where the
// entropy for the codes comes from. `docs/DEVICE-AUTH.md` works the first
// one through `ApiKeys`.
struct SealbKeys;
#[async_trait::async_trait]
impl Issuer for SealbKeys {
    async fn issue(&self, request: IssueRequest) -> Result<serde_json::Value, IssuerError> {
        // In a venture this calls the key-minting you already have.
        Ok(serde_json::json!({ "api_key": "sealb_live_…", "subject": request.subject }))
    }
}
struct OsEntropy;
impl RandomBytes for OsEntropy {
    fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError> {
        // In a venture this is the platform CSPRNG (e.g. `getrandom`).
        for byte in dest.iter_mut() {
            *byte = 0x2a;
        }
        Ok(())
    }
}

let module = DeviceAuth::builder()
    .client(DeviceClient::new("sealb-cli").scopes(["read", "write"]))
    .issuer(SealbKeys)
    .random(OsEntropy)
    .sign_in_url("/login")           // where CallerApprover sends anonymous visitors
    .expires_in(time::Duration::seconds(600))
    .interval(time::Duration::seconds(5))
    .max_wrong_entries(5)
    .build();

.approver(..) overrides the approver. Left unset, the module uses CallerApprover, which reads the Auth port’s caller and sends an anonymous browser to the sign-in URL with return_to appended, so the person comes straight back to the code.

self_check refuses a composition that cannot work: no clients, no issuer, no entropy source, or a client whose id or scopes are malformed.

§The two hooks

  • Approver answers who is asking to approve. CallerApprover is the stock one over the Auth port; a venture with its own session scheme implements the trait directly.
  • Issuer answers what credential does this client get. It is called exactly once per approved request and returns the JSON body the client receives. docs/DEVICE-AUTH.md shows an issuer built on ApiKeys::issue.

§The endpoints

RouteWho calls itWhat it answers
POST /v1/device-auth/codethe clientthe code pair and the verification URL
POST /v1/device-auth/tokenthe clientthe credential, or an OAuth error while pending
GET /v1/device-auththe browserthe code form, or the Approve/Deny page
POST /v1/device-auth/approvethe browseran approval page
POST /v1/device-auth/denythe browsera denial page

The two machine routes take their parameters as a JSON body or an application/x-www-form-urlencoded form, and answer RFC 6749 §5.2 errors ({"error": ..., "error_description": ...}) with a 400. slow_down and authorization_pending are normal parts of the flow, not failures.

§Storage

One table, device_auth_codes, in the venture’s own database. Both codes are stored only as SHA-256 hashes — there is no clear-text column — so a dump, a backup or a query log cannot be replayed as a code. The migration is crates/module-device-auth/migrations/sqlite/0001_init.sql.

§The client side

cratefield_oauth_client::device is the client half: it runs the request-code / poll / handle-slow_down loop for a binary that is already using the OAuth client crate.

§Ports

PortHow
Databaserequired — the codes, their state and their expiry live here
RateLimiterrequired — the abuse control the public routes have (issue #562)
Clockoptional — what makes expiry and the poll interval deterministic
Authoptional — what CallerApprover identifies callers with

§Documentation

  • docs/DEVICE-AUTH.md — the flow, the endpoints, the hooks, and the at-most-once credential rule.

Structs§

ApproverError
Why an approver could not answer.
CallerApprover
The stock approver: the Auth port’s Caller. A verified caller is a subject; an anonymous one is sent to sign_in_url with return_to appended.
DeviceAuth
The device authorization grant (RFC 8628) over the harness’s own ports.
DeviceAuthBuilder
Builds a DeviceAuth. Everything the module cannot decide for a venture — who its clients are, who may approve, what a credential looks like, where entropy comes from — is set here.
DeviceClient
One client allowed to run the device grant. Declaring it is what makes its client_id valid and its scopes the only scopes it may ask for.
IssueRequest
What an Issuer is asked to mint a credential for.
IssuerError
Why an issuer could not mint a credential.

Enums§

Approval
What an Approver says about the person behind a request.

Constants§

DEFAULT_EXPIRES_IN
How long a device code stays usable by default: ten minutes.
DEFAULT_INTERVAL
The interval RFC 8628 §3.5 asks a client to poll at by default.
DEFAULT_MAX_WRONG_ENTRIES
The wrong-entry allowance per approver before the limiter refuses, by default.
MAX_EXPIRES_IN
The longest a device code may live: 24 hours. RFC 8628 §3.2’s expires_in is a hint to the client, and a code that outlives a working day is one nobody is waiting for.

Traits§

Approver
Decides who may approve a device. The browser cannot reach the approval page or submit a decision without passing through one of these.
Issuer
Mints the credential a client receives once its device code is approved.