deviceauth

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

README

deviceauth

The device authorization grant (RFC 8628), better-auth's device-authorization plugin: a client that cannot open a browser (a CLI, a TV, a set-top box) asks for a device code and a short user code, shows the code and a link, and polls while the user signs in on a phone or laptop and approves. The approval ends with a session of the approving user on the polling client.

It is its own module, github.com/ubgo/auth/plugin/deviceauth, so an app that signs in only through a browser never compiles it. It needs nothing but the core: any sign-in method lets the user reach the approval page.

dp := deviceauth.New(
	deviceauth.ValidateClient(func(ctx context.Context, id string) (bool, error) { return id == "my-cli", nil }),
)
a, err := auth.New(auth.WithDB(db, dialect), auth.WithBaseURL("https://app.example.com"),
	auth.WithPlugins(email.New(...), password.New(), dp))

mux.Handle("POST /auth/device/code", dp.CodeHandler())   // RFC 8628 error shape for stock clients
mux.Handle("POST /auth/device/token", dp.TokenHandler())
mux.Handle("/auth/", authhttp.Handler(a, authhttp.Prefix("/auth")))

Routes

Route Requirement What it does
POST /device/code public client_id (required), scope; answers device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval with Cache-Control: no-store
POST /device/token public grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code, client_id; answers access_token (the new session's token), token_type: Bearer, expires_in, scope, and sets the session cookie or bearer header
GET /device/verify?user_code= public the approval page's lookup: the status for anyone; a signed-in caller claims a pending unclaimed code, and only the code's owner sees client_id and scope
POST /device/approve {"userCode"} signed in the owner approves a pending, claimed code
POST /device/deny {"userCode"} signed in the owner denies it

better-auth serves the lookup at GET /device; a route here is always /<plugin>/<path>, so it is GET /device/verify. From Go: Code, Token, Verify, Approve, Deny, each through the same pipeline. Only Code from Go may set CodeRequest.UserID, which pre-binds the code to one user (no claim needed, nobody else may decide it); it is not part of the HTTP API (json:"-"), because better-auth documents it for server-side use: the generated clients do not offer it, the JSON route refuses user_id as an unknown field, and the RFC handler ignores it as an unrecognized parameter (RFC 6749 section 3.2).

Grants (an OAuth provider)

WithGrant(g) installs better-auth's DeviceAuthorizationGrant seam: g.AuthorizeCode(ctx, call, in) runs at /device/code instead of ValidateClient, authenticates the client with the request's client_secret, client_assertion or Authorization header, checks scope and resource, and returns a Binding naming the grant and the resource. A code bound to a grant is never redeemed for a session at /device/token (invalid_grant); the grant's own token endpoint redeems it with p.Redeem(ctx, deviceCode, clientID, grant), which runs the same state machine and returns the Redemption (user, scope, resource). The approval page shows the owner the resource too. A Binding with an empty Grant leaves the request to the standalone flow. An error that implements RFCCoder (RFCCode() string) is answered with its own code by the RFC handlers, so invalid_scope and invalid_target pass through.

Polling

/device/token is better-auth's redeemDeviceCode state machine. A poll within the code's interval answers slow_down and adds 5 seconds to the interval for every later poll (RFC 8628 §3.5); a pending code answers authorization_pending; an expired code answers expired_token and is deleted; a denied code answers access_denied and is deleted; an approved code is deleted by a conditional delete and only the poll whose delete removed it gets a session, so two racing polls end with one session. The poll time and the grown interval are written after the call's transaction, which the error rolls back.

The new session skips the second factor: the approving session already passed it, as with a hand-off.

The device side in Go

Client{CodeURL, TokenURL, ClientID} is better-auth's deviceAuthorizationClient() for a Go program: RequestCode(ctx, scope) returns the CodeResult to show, and Poll(ctx, code) polls until the user decides, honouring the interval, slow_down (5 more seconds each time, RFC 8628 §3.5) and a 429/temporarily_unavailable Retry-After. Any other refusal ends it as a *ClientError with the RFC code and status. Standard library only; HTTP and Sleep are replaceable, the second so a test can move a fake clock. ExampleClient is a runnable CLI login, and examples/device-login is a whole CLI and web app: go run ./device-login -login prints the code, polls, and signs in as the person who approved.

Options

Option Default better-auth
WithExpiresIn(d) 30 minutes expiresIn
WithInterval(d) 5 seconds interval
DeviceCodeLength(n) 40 (max 191) deviceCodeLength
UserCodeLength(n) 8 (max 191) userCodeLength
GenerateDeviceCode(fn) random A-Z a-z 0-9 generateDeviceCode
GenerateUserCode(fn) random ABCDEFGHJKLMNPQRSTUVWXYZ23456789 generateUserCode
ValidateClient(fn) every client id validateClient
OnDeviceAuthRequest(fn) none onDeviceAuthRequest
VerificationURI(u) /device, resolved against the request's base URL verificationUri
WithVerifyLimit(n, window) 5 per IP per code lifetime the /device rate rule

A default-alphabet user code is matched ignoring case, spaces and dashes (NormalizeUserCode), so wdjb-mjht finds WDJBMJHT; a custom generator's code is matched exactly. A generator result longer than 191 characters, or one still in use after three draws, answers server_error.

Table

auth_device_code (alias dac): id, device_code_hash and user_code_hash (SHA-256, unique), user_id, client_id, scope, status (pending, approved, denied), expires_at, last_polled_at, polling_interval_ms, grant_name and resource (set by a Grant), created_at. Both codes are stored hashed, so a database read hands nobody a live approval. No foreign key (ADR-0002): UserTables lists user_id, so DeleteUser removes a user's claimed codes, and Sweep deletes expired ones.

Errors

Code Status RFC 8628 error
device.invalid_request 400 invalid_request
device.unsupported_grant_type 400 unsupported_grant_type
device.invalid_client 400 invalid_client
device.invalid_device_code 400 invalid_grant
device.expired_device_code 400 expired_token
device.authorization_pending 400 authorization_pending
device.polling_too_frequently 400 slow_down
device.access_denied 400 access_denied
device.not_owner 403 access_denied
device.invalid_user_code 400 invalid_request
device.expired_user_code 400 expired_token
device.already_processed 400 invalid_request
device.not_claimed 400 invalid_request
device.server_error 500 server_error

authhttp.Handler answers these in the library's error JSON. CodeHandler and TokenHandler answer {"error":"…","error_description":"…","auth_error":{"code":"device.…","message":"…"}} with status 400 (auth_error, authhttp.ProtocolErrorMember, is the library's own error, which RFC clients ignore and the generated TypeScript client reads) (a rate limit keeps 429 and Retry-After, a failure keeps 500), take a form or JSON body, and refuse a parameter sent twice with two non-empty values. RFCErrorCode(err) gives the mapping to an app with its own handler.

Security notes

  • Device-code phishing is inherent to RFC 8628: whoever gets a user to approve a code they did not start gets a session. The approval page shows the client id and scope to the owner for that reason; show them prominently and ask the user to confirm the code matches their device.
  • GET /device/verify claims a code for a signed-in caller, as better-auth's GET /device does. A cross-site request can therefore claim a code for a victim, but cannot approve it: approve and deny are origin-checked mutations.
  • The verify rate limit and a short code lifetime bound user-code guessing (40 bits by default).
  • The OAuth provider integration (oauthDeviceAuthorization) is the separate module oauthdevice: registered clients get OAuth tokens from plugin/oauthprovider's token endpoint.

Documentation

Overview

Package deviceauth is the device authorization grant (RFC 8628), better-auth's device-authorization plugin: a client that cannot open a browser (a CLI, a TV, an IoT box) asks for a device code and a short user code, shows the user code and a link, and polls while the user signs in on another device and approves. The approval ends with a session of the approving user on the polling client.

The flow, as five operations under the plugin name "device":

POST /device/code     the client gets device_code, user_code, the links, expires_in and interval
GET  /device/verify   the approval page checks a user code; a signed-in caller claims it
POST /device/approve  the claiming user approves
POST /device/deny     or denies
POST /device/token    the client polls: authorization_pending, slow_down, expired_token,
                      access_denied, or the session (access_token, token_type Bearer)

Both codes are stored as SHA-256 hashes, so a database read hands nobody a pending approval, and a code is found by its hash alone. The approved row is spent by a conditional delete, so two polls racing for one approval end with one session. The error codes RFC 8628 names are answered in its own shape by CodeHandler and TokenHandler; the default authhttp.Handler answers the same operations in the library's error JSON.

Index

Examples

Constants

View Source
const (
	EndpointCode    auth.EndpointID = "device.code"
	EndpointToken   auth.EndpointID = "device.token"
	EndpointVerify  auth.EndpointID = "device.verify"
	EndpointApprove auth.EndpointID = "device.approve"
	EndpointDeny    auth.EndpointID = "device.deny"
)

Operation ids, exported so a hook or a rate rule can name them.

View Source
const (
	// DefaultExpiresIn is how long a device code can be approved and polled:
	// long enough to find a phone and sign in, short enough that a code
	// photographed off a screen is soon worthless.
	//ds:def id=deviceauth-defaultexpiresin-4p8wn2xk owner=@khanakia stability=stable
	DefaultExpiresIn = 30 * time.Minute
	// DefaultInterval is the least time between two polls; a faster poll
	// answers slow_down.
	//ds:def id=deviceauth-defaultinterval-9t2vk5qm owner=@khanakia stability=stable
	DefaultInterval = 5 * time.Second
	// SlowDownStep is what RFC 8628 §3.5 adds to the interval each time a
	// client polls too fast, "for this and all subsequent requests".
	//ds:def id=deviceauth-slowdownstep-3k8xq2nv owner=@khanakia stability=stable
	SlowDownStep = 5 * time.Second
	// DefaultDeviceCodeLength is 40 characters of [A-Za-z0-9], about 238
	// bits: the device code is the credential the poller holds.
	//ds:def id=deviceauth-defaultdevicecodelength-6w3mz8rq owner=@khanakia stability=stable
	DefaultDeviceCodeLength = 40
	// DefaultUserCodeLength is 8 characters of UserCodeAlphabet, 40 bits: a
	// person types it, and the verify rate limit and the code's short life
	// bound guessing.
	//ds:def id=deviceauth-defaultusercodelength-2n7kv4xp owner=@khanakia stability=stable
	DefaultUserCodeLength = 8
	// MaxCodeLength bounds both codes and a generator's output: 191 is the
	// longest key MySQL indexes in utf8mb4, better-auth's
	// DEVICE_AUTHORIZATION_CODE_MAX_LENGTH.
	//ds:def id=deviceauth-maxcodelength-8r4tw6zn owner=@khanakia stability=stable
	MaxCodeLength = 191
	// DefaultVerificationURI is the approval page, relative to the base URL.
	//ds:def id=deviceauth-defaultverificationuri-5q9xm3kt owner=@khanakia stability=stable
	DefaultVerificationURI = "/device"
)

Defaults, better-auth's.

View Source
const (
	TableNameCode  = "auth_device_code"
	TableAliasCode = "dac"

	ColID              = "id"
	ColDeviceCodeHash  = "device_code_hash"
	ColUserCodeHash    = "user_code_hash"
	ColUserID          = "user_id"
	ColClientID        = "client_id"
	ColScope           = "scope"
	ColStatus          = "status"
	ColExpiresAt       = "expires_at"
	ColLastPolledAt    = "last_polled_at"
	ColPollingInterval = "polling_interval_ms"
	ColCreatedAt       = "created_at"
	// ColGrant names the Grant a code was issued through ("" for the
	// standalone flow); ColResource is its RFC 8707 resource.
	ColGrant    = "grant_name"
	ColResource = "resource"
)

Table, alias and column names.

View Source
const (
	RFCInvalidRequest       = "invalid_request"
	RFCInvalidClient        = "invalid_client"
	RFCInvalidGrant         = "invalid_grant"
	RFCUnsupportedGrantType = "unsupported_grant_type"
	RFCAuthorizationPending = "authorization_pending"
	RFCSlowDown             = "slow_down"
	RFCAccessDenied         = "access_denied"
	RFCExpiredToken         = "expired_token"
	RFCServerError          = "server_error"
	// RFCInvalidTarget is RFC 8707's answer for a resource the server does
	// not serve, which a Grant's error reports through RFCCoder.
	RFCInvalidTarget = "invalid_target"
	// RFCTemporarilyUnavailable answers a rate-limited request, RFC 6749
	// §4.1.2.1's code for "try again later".
	RFCTemporarilyUnavailable = "temporarily_unavailable"
)

RFC 6749 / RFC 8628 error codes, the "error" member CodeHandler and TokenHandler answer. A device client library (golang.org/x/oauth2's DeviceAccessToken, for one) switches on these exact strings.

View Source
const GrantType = "urn:ietf:params:oauth:grant-type:device_code"

GrantType is the grant_type a poll must send (RFC 8628 §3.4).

View Source
const PluginName = "device"

PluginName is the plugin's permanent identity and its route prefix, so the routes are better-auth's: /device/code, /device/token, /device/approve.

View Source
const TokenType = "Bearer"

TokenType is the token_type of a successful poll: the session token is a bearer credential.

View Source
const UserCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"

UserCodeAlphabet is the default user code alphabet: upper-case letters and digits without 0, O, 1 and I, which a person misreads.

Variables

View Source
var (
	CodeID              = sqlb.Col[string](TableCode, ColID)
	CodeDeviceCodeHash  = sqlb.Col[[]byte](TableCode, ColDeviceCodeHash)
	CodeUserCodeHash    = sqlb.Col[[]byte](TableCode, ColUserCodeHash)
	CodeUserID          = sqlb.Col[*string](TableCode, ColUserID)
	CodeClientID        = sqlb.Col[string](TableCode, ColClientID)
	CodeScope           = sqlb.Col[string](TableCode, ColScope)
	CodeStatus          = sqlb.Col[Status](TableCode, ColStatus)
	CodeExpiresAt       = sqlb.Col[time.Time](TableCode, ColExpiresAt)
	CodeLastPolledAt    = sqlb.Col[*time.Time](TableCode, ColLastPolledAt)
	CodePollingInterval = sqlb.Col[int64](TableCode, ColPollingInterval)
	CodeCreatedAt       = sqlb.Col[time.Time](TableCode, ColCreatedAt)
	CodeGrant           = sqlb.Col[*string](TableCode, ColGrant)
	CodeResource        = sqlb.Col[*string](TableCode, ColResource)
)

Typed columns of TableCode.

View Source
var (
	ErrBadExpiresIn = errors.New("deviceauth: WithExpiresIn needs a positive duration")
	ErrBadInterval  = errors.New("deviceauth: WithInterval needs a positive duration")
	ErrBadLength    = fmt.Errorf("deviceauth: code lengths must be 1 to %d", MaxCodeLength)
	ErrBadLimit     = errors.New("deviceauth: WithVerifyLimit needs a positive limit and window")
)

Configuration errors from auth.New.

View Source
var (
	// ErrInvalidRequest is a missing client_id, a repeated parameter, a value
	// too long for its column, or a UserID set anywhere but Code.
	ErrInvalidRequest = auth.NewError("device.invalid_request", http.StatusBadRequest, "The request is missing a parameter or has a bad one.")
	// ErrUnsupportedGrantType is a poll with a grant_type other than GrantType.
	ErrUnsupportedGrantType = auth.NewError("device.unsupported_grant_type", http.StatusBadRequest, "The grant type is not supported.")
	// ErrInvalidClient is a client ValidateClient refused when asking for a code.
	ErrInvalidClient = auth.NewError("device.invalid_client", http.StatusBadRequest, "The client is not allowed to use device authorization.")
	// ErrInvalidDeviceCode is an unknown device code, a client that is not
	// the one the code was issued to, or a poll that lost the race for one
	// approval.
	ErrInvalidDeviceCode = auth.NewError("device.invalid_device_code", http.StatusBadRequest, "The device code is not valid.")
	// ErrExpiredDeviceCode is a poll after the code expired; the row is deleted.
	ErrExpiredDeviceCode = auth.NewError("device.expired_device_code", http.StatusBadRequest, "The device code has expired.")
	// ErrAuthorizationPending is a poll before the user decided.
	ErrAuthorizationPending = auth.NewError("device.authorization_pending", http.StatusBadRequest, "The user has not approved the request yet.")
	// ErrPollingTooFrequently is a poll within the interval; the interval
	// grows by SlowDownStep.
	ErrPollingTooFrequently = auth.NewError("device.polling_too_frequently", http.StatusBadRequest, "Polling too often. Wait longer between requests.")
	// ErrAccessDenied is a poll after the user denied; the row is deleted.
	ErrAccessDenied = auth.NewError("device.access_denied", http.StatusBadRequest, "The user denied the request.")
	// ErrNotOwner is an approve or deny by a user other than the one who
	// claimed the code.
	ErrNotOwner = auth.NewError("device.not_owner", http.StatusForbidden, "This code belongs to another account.")
	// ErrInvalidUserCode is an unknown user code on the approval page.
	ErrInvalidUserCode = auth.NewError("device.invalid_user_code", http.StatusBadRequest, "This code is not valid. Check it and try again.")
	// ErrExpiredUserCode is a user code past its expiry on the approval page.
	ErrExpiredUserCode = auth.NewError("device.expired_user_code", http.StatusBadRequest, "This code has expired. Start again on your device.")
	// ErrAlreadyProcessed is an approve or deny of a code already decided.
	ErrAlreadyProcessed = auth.NewError("device.already_processed", http.StatusBadRequest, "This code was already used.")
	// ErrNotClaimed is an approve or deny of a code no one has opened on the
	// approval page yet (GET verify while signed in claims it).
	ErrNotClaimed = auth.NewError("device.not_claimed", http.StatusBadRequest, "Open this code on the approval page first.")
	// ErrServerError is a generator that kept returning codes in use or too
	// long, or an approved code whose user is gone.
	ErrServerError = auth.NewError("device.server_error", http.StatusInternalServerError, "The request could not be completed.")
)

Request errors, better-auth's $ERROR_CODES. Each is mapped to the RFC 8628 error code CodeHandler and TokenHandler answer (RFCErrorCode).

View Source
var ErrClientConfig = errors.New("deviceauth: a Client needs CodeURL, TokenURL and ClientID")

ErrClientConfig is a Client missing its CodeURL, TokenURL or ClientID.

StatusValues is the canonical iteration order over statuses.

View Source
var TableCode = sqlb.Table{Name: TableNameCode, Alias: TableAliasCode}

TableCode is the device code table. Reads are open: an admin page may list pending approvals; writes go through the operations.

Functions

func NormalizeUserCode

func NormalizeUserCode(code string) string

NormalizeUserCode upper-cases a default-alphabet user code and drops every character outside its alphabet, so "wdjb-mjht" and "WDJB MJHT" match WDJBMJHT, as better-auth matches them.

func RFCErrorCode

func RFCErrorCode(err error) string

RFCErrorCode returns the RFC 8628 error code for an error of this plugin, the code of an RFCCoder, and server_error for anything else.

Types

type Binding

type Binding struct {
	Grant    string
	Resource string
	// Scope, when set, is the scope stored on the device code in place of
	// the requested one: the grant's narrowed scopes (a resource's
	// allowlist), so the approval page shows what will be granted.
	Scope string
}

Binding is a Grant's decision for one code request.

type Client

type Client struct {
	// CodeURL is where CodeHandler is mounted, such as
	// "https://app.example.com/auth/device/code".
	CodeURL string
	// TokenURL is where TokenHandler is mounted.
	TokenURL string
	// ClientID is sent with every request.
	ClientID string
	// HTTP sends the requests; nil is http.DefaultClient.
	HTTP *http.Client
	// Sleep waits between polls and returns ctx's error when ctx ends first;
	// nil waits on a timer. A test replaces it to move a fake clock.
	Sleep func(ctx context.Context, d time.Duration) error
}

Client is the device side of the flow in Go, better-auth's deviceAuthorizationClient: a CLI or any program without a browser asks CodeHandler for a code, shows the user code and verification URI, then polls TokenHandler until the user approves or denies. It speaks the RFC 8628 wire format only (form bodies, {"error": ...} answers), so it works against any RFC 8628 server, this plugin's or another's. Standard library only; golang.org/x/oauth2's device flow is the alternative for an app that already imports it.

Example

ExampleClient is the device side of a CLI login, better-auth's `npx auth login` demo in Go: request a code, show it, poll until the user approves on another device. The server here is a stand-in that approves at once; in an app, CodeURL and TokenURL are where CodeHandler and TokenHandler are mounted.

mux := http.NewServeMux()
mux.HandleFunc("POST /auth/device/code", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, `{"device_code":"d","user_code":"WDJB-MJHT","verification_uri":"https://app.example.com/device","expires_in":900,"interval":5}`)
})
mux.HandleFunc("POST /auth/device/token", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, `{"access_token":"session-token","token_type":"Bearer","expires_in":604800}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()

c := &Client{CodeURL: srv.URL + "/auth/device/code", TokenURL: srv.URL + "/auth/device/token", ClientID: "my-cli",
	Sleep: func(context.Context, time.Duration) error { return nil }} // a real CLI leaves Sleep nil
ctx := context.Background()
code, err := c.RequestCode(ctx, "")
if err != nil {
	fmt.Println(err)
	return
}
fmt.Printf("Open %s and enter %s\n", code.VerificationURI, code.UserCode)
tok, err := c.Poll(ctx, code)
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println("signed in; send", tok.TokenType, "tokens from now on")
Output:
Open https://app.example.com/device and enter WDJB-MJHT
signed in; send Bearer tokens from now on

func (*Client) Poll

func (c *Client) Poll(ctx context.Context, code CodeResult) (TokenResult, error)

Poll asks for the token until the user decides (RFC 8628 §3.4, §3.5): it waits the code's interval before each request, keeps going on authorization_pending, adds SlowDownStep (5s, the server's own step) to the interval on slow_down, and waits a 429 or temporarily_unavailable answer's Retry-After (or the interval). Any other refusal ends it as a *ClientError: the user denied (RFCAccessDenied), the code expired (RFCExpiredToken), and so on. It also ends when ctx does.

func (*Client) RequestCode

func (c *Client) RequestCode(ctx context.Context, scope string) (CodeResult, error)

RequestCode asks for a device code (RFC 8628 §3.1). scope may be empty. Show the user UserCode and VerificationURI (or VerificationURIComplete), then call Poll with the result.

type ClientError

type ClientError struct {
	Status      int
	ErrorName   string
	Description string
}

ClientError is a refusal the server answered in RFC 6749 §5.2's shape. ErrorName is the "error" member (RFCAccessDenied, RFCExpiredToken, RFCInvalidClient, ...), Status the HTTP status. An answer that is not the RFC shape is a ClientError with ErrorName RFCServerError.

func (*ClientError) Error

func (e *ClientError) Error() string

Error names the RFC error code, and the server's description when it sent one.

type CodeRequest

type CodeRequest struct {
	ClientID string `json:"client_id"`
	Scope    string `json:"scope,omitempty"`
	UserID   string `json:"-"`
	// Confidential-client credentials and the RFC 8707 resource, read only
	// by a Grant (WithGrant); the standalone flow ignores them.
	ClientSecret        string `json:"client_secret,omitempty"`
	ClientAssertion     string `json:"client_assertion,omitempty"`
	ClientAssertionType string `json:"client_assertion_type,omitempty"`
	Resource            string `json:"resource,omitempty"`
}

CodeRequest is the input of /device/code. UserID pre-binds the code to one user, better-auth's user_id: only that user may approve or deny it, and no claim is needed. It is set from Go (Code) only, because better-auth documents it for server-side use and a client has no business naming a user: it is not part of the wire (json:"-"), so no transport decodes it, the generated clients never offer it, and the RFC handlers ignore a user_id parameter as RFC 6749 section 3.2 says to treat an unrecognized one. An operation reached any other way than Code (Operation.Invoke with a decoder that sets it) is still refused with ErrInvalidRequest.

type CodeResult

type CodeResult struct {
	DeviceCode              string `json:"device_code"`
	UserCode                string `json:"user_code"`
	VerificationURI         string `json:"verification_uri"`
	VerificationURIComplete string `json:"verification_uri_complete"`
	ExpiresIn               int    `json:"expires_in"`
	Interval                int    `json:"interval"`
}

CodeResult is the answer of /device/code, RFC 8628 §3.2. The device code is the poller's credential and goes to that client only; the user code is what the user types. Durations are whole seconds.

type DecisionRequest

type DecisionRequest struct {
	UserCode string `json:"userCode"`
}

DecisionRequest is the input of approve and deny, better-auth's userCode.

type DecisionResult

type DecisionResult struct {
	Success bool `json:"success"`
}

DecisionResult is the answer of approve and deny.

type Generator

type Generator func(ctx context.Context) (string, error)

Generator returns a new code; better-auth's generateDeviceCode and generateUserCode. A result longer than MaxCodeLength fails the request.

type Grant

type Grant interface {
	AuthorizeCode(ctx context.Context, c *auth.Call, in CodeRequest) (Binding, error)
}

Grant is better-auth's DeviceAuthorizationGrant: an extension that issues codes on behalf of another token endpoint (an OAuth provider). It authorizes each /device/code request instead of ValidateClient (authenticating a confidential client, checking scope and resource) and names itself in the Binding; a code bound to a grant is redeemed only through Redeem with that name, never for a session at /device/token. A Binding with an empty Grant leaves the request to the standalone flow.

type Option

type Option func(*Plugin)

Option configures the plugin.

func DeviceCodeLength

func DeviceCodeLength(n int) Option

DeviceCodeLength overrides DefaultDeviceCodeLength (1 to MaxCodeLength).

func GenerateDeviceCode

func GenerateDeviceCode(fn Generator) Option

GenerateDeviceCode replaces the random device code. Risk: the device code is the poller's credential; a guessable generator signs in whoever guesses.

func GenerateUserCode

func GenerateUserCode(fn Generator) Option

GenerateUserCode replaces the random user code. A custom code is matched exactly; only codes of the default alphabet are normalized (NormalizeUserCode).

func OnDeviceAuthRequest

func OnDeviceAuthRequest(fn func(ctx context.Context, clientID, scope string) error) Option

OnDeviceAuthRequest runs fn on every code request before a code is drawn, better-auth's onDeviceAuthRequest; an error fails the request.

func UserCodeLength

func UserCodeLength(n int) Option

UserCodeLength overrides DefaultUserCodeLength (1 to MaxCodeLength).

func ValidateClient

func ValidateClient(fn func(ctx context.Context, clientID string) (bool, error)) Option

ValidateClient allows only the client ids fn accepts, better-auth's validateClient: a refused id answers invalid_client at /device/code and invalid_grant at /device/token. Without it every client id is accepted.

func VerificationURI

func VerificationURI(u string) Option

VerificationURI overrides DefaultVerificationURI, the page a user opens. An absolute URL is used as is; a relative one is resolved against the request's base URL.

func WithExpiresIn

func WithExpiresIn(d time.Duration) Option

WithExpiresIn overrides DefaultExpiresIn, better-auth's expiresIn.

func WithGrant

func WithGrant(g Grant) Option

WithGrant installs a Grant; only one, as better-auth allows one device authorization integration.

func WithInterval

func WithInterval(d time.Duration) Option

WithInterval overrides DefaultInterval, better-auth's interval.

func WithVerifyLimit

func WithVerifyLimit(n int, window time.Duration) Option

WithVerifyLimit replaces the default of 5 verifications per IP per code lifetime.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the device authorization plugin. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin with better-auth's defaults.

func (*Plugin) Approve

func (p *Plugin) Approve(ctx context.Context, userCode string) error

Approve approves a claimed code as the signed-in caller.

func (*Plugin) Code

func (p *Plugin) Code(ctx context.Context, in CodeRequest) (CodeResult, error)

Code asks for a device code from Go, through the same operation as the route. Only this path may set UserID.

func (*Plugin) CodeHandler

func (p *Plugin) CodeHandler(opts ...authhttp.Option) http.Handler

CodeHandler serves /device/code in RFC 8628's own shape: a form or JSON body, the RFC error body ({"error":"invalid_client"}, with the library's own error beside it under "auth_error"), Cache-Control: no-store. Mount it where authhttp.Handler would serve the route, so a stock device client and the library's TypeScript client both work unchanged; both run the same operation.

func (*Plugin) Deny

func (p *Plugin) Deny(ctx context.Context, userCode string) error

Deny denies a claimed code as the signed-in caller.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core handle.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists the five operations.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds user-code guessing on the approval page, better-auth's rule on /device. Polling is bounded by slow_down instead, and the device code is too long to guess.

func (*Plugin) Redeem

func (p *Plugin) Redeem(ctx context.Context, deviceCode, clientID, grant string) (Redemption, error)

Redeem runs the polling state machine for a Grant's own token endpoint, better-auth's redeemDeviceCode: the same answers as /device/token (ErrAuthorizationPending, ErrPollingTooFrequently, ErrExpiredDeviceCode, ErrAccessDenied, ErrInvalidDeviceCode), and, for an approved code issued through the Grant named grant, the spent code's Redemption. A code issued through another grant, or the standalone flow, is ErrInvalidDeviceCode. It must run inside the caller's operation (its context), whose transaction the spend joins.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes codes that expired before cutoff; a poll deletes the ones it sees expire, and this catches the clients that stopped polling.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the device code table.

func (*Plugin) Token

func (p *Plugin) Token(ctx context.Context, in TokenRequest) (TokenResult, error)

Token polls for the session from Go.

func (*Plugin) TokenHandler

func (p *Plugin) TokenHandler(opts ...authhttp.Option) http.Handler

TokenHandler serves /device/token in RFC 8628's own shape, as CodeHandler does for /device/code: authorization_pending, slow_down, expired_token and access_denied come back as {"error": ...} with status 400.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the device code table.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that a claimed code belongs to its user, so DeleteUser removes it (no foreign key does, ADR-0002).

func (*Plugin) Validate

func (p *Plugin) Validate(*auth.Config) error

Validate refuses lifetimes and lengths nobody could use.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, userCode string) (VerifyResult, error)

Verify looks a user code up, claiming it for the signed-in caller.

type RFCCoder

type RFCCoder interface {
	RFCCode() string
}

RFCCoder is an error that names its own RFC error code, for a Grant whose refusals (invalid_scope, invalid_target) this plugin does not know.

type Redemption

type Redemption struct {
	UserID   string
	Scope    string
	Resource string
}

Redemption is an approved device code, spent: the user who approved it, the scope and the resource it was issued for.

type Status

type Status string

Status is the state of a device code.

const (
	StatusPending  Status = "pending"
	StatusApproved Status = "approved"
	StatusDenied   Status = "denied"
)

Statuses. A code starts pending, is approved or denied once, and is deleted by the poll that reads the decision.

type TokenRequest

type TokenRequest struct {
	GrantType  string `json:"grant_type"`
	DeviceCode string `json:"device_code"`
	ClientID   string `json:"client_id"`
}

TokenRequest is a poll of /device/token, RFC 8628 §3.4.

type TokenResult

type TokenResult struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   int    `json:"expires_in"`
	Scope       string `json:"scope"`
	// UserID is the signed-in user, for a Go caller; not part of the RFC answer.
	UserID string `json:"-"`
}

TokenResult is the answer of an approved poll, RFC 6749 §5.1: the new session's token, carried in the body because handing it to a client with no cookie jar is the operation's purpose. A cookie or bearer header is set as well, as on every sign-in.

type VerifyRequest

type VerifyRequest struct {
	UserCode string `json:"user_code"`
}

VerifyRequest is the input of the approval page's lookup.

type VerifyResult

type VerifyResult struct {
	UserCode string `json:"user_code"`
	Status   Status `json:"status"`
	ClientID string `json:"client_id,omitempty"`
	Scope    string `json:"scope,omitempty"`
	// Resource is the RFC 8707 resource of a code issued through a Grant.
	Resource string `json:"resource,omitempty"`
	// Owned is true when the code belongs to the caller: claimed now, or
	// earlier, or pre-bound.
	Owned bool `json:"owned"`
}

VerifyResult is what the approval page shows. ClientID and Scope are given only to the user the code belongs to, so a stranger who types a code learns nothing about the client that asked.

Directories

Path Synopsis
oauthdevice module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL