From a3b8a71a0394ac6caf75121915418a802948fd2e Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 14:48:43 -0700 Subject: [PATCH 1/9] feat: add Device Authorization Grant (RFC 8628) Implements the full device authorization flow for headless devices and CLI tools. Includes device_authorization endpoint, user verification UI, token polling with slow_down/authorization_pending/expired_token errors, and cleanup integration. Closes #264 Co-Authored-By: Claude Opus 4.6 --- pkg/appsettings/load.go | 12 + pkg/cleanup/service.go | 1 + pkg/cli/start.go | 3 + pkg/client/model.go | 4 +- pkg/config/config.go | 7 + pkg/db/migrations/008_device_codes.go | 20 ++ pkg/db/migrations/migrations.go | 3 +- pkg/devicecode/create.go | 12 + pkg/devicecode/devicecode_test.go | 154 +++++++++++++ pkg/devicecode/handler.go | 294 +++++++++++++++++++++++++ pkg/devicecode/handler_test.go | 157 +++++++++++++ pkg/devicecode/model.go | 27 +++ pkg/devicecode/read.go | 27 +++ pkg/devicecode/service.go | 51 +++++ pkg/devicecode/service_test.go | 54 +++++ pkg/devicecode/update.go | 31 +++ pkg/model/well_known_config.go | 2 + pkg/token/device_code.go | 78 +++++++ pkg/token/device_code_test.go | 198 +++++++++++++++++ pkg/token/handler.go | 7 + pkg/token/model.go | 3 +- pkg/wellknown/handler.go | 3 +- tests/security/security_oauth2_test.go | 1 - view/device_confirm.html | 16 ++ view/device_denied.html | 9 + view/device_success.html | 9 + view/device_verify.html | 15 ++ 27 files changed, 1192 insertions(+), 6 deletions(-) create mode 100644 pkg/db/migrations/008_device_codes.go create mode 100644 pkg/devicecode/create.go create mode 100644 pkg/devicecode/devicecode_test.go create mode 100644 pkg/devicecode/handler.go create mode 100644 pkg/devicecode/handler_test.go create mode 100644 pkg/devicecode/model.go create mode 100644 pkg/devicecode/read.go create mode 100644 pkg/devicecode/service.go create mode 100644 pkg/devicecode/service_test.go create mode 100644 pkg/devicecode/update.go create mode 100644 pkg/token/device_code.go create mode 100644 pkg/token/device_code_test.go create mode 100644 view/device_confirm.html create mode 100644 view/device_denied.html create mode 100644 view/device_success.html create mode 100644 view/device_verify.html diff --git a/pkg/appsettings/load.go b/pkg/appsettings/load.go index fa5c276b..ebc90278 100644 --- a/pkg/appsettings/load.go +++ b/pkg/appsettings/load.go @@ -69,6 +69,8 @@ var defaults = map[string]string{ "profile_field_locale": "hidden", "profile_field_address": "optional", "footer_links": "[]", + "device_code_expiration": "10m", + "device_code_polling_interval": "5", "cors_allowed_origins": "", } @@ -303,6 +305,16 @@ func LoadIntoConfig() error { } } + if v, ok := all["device_code_expiration"]; ok { + cfg.DeviceCodeExpirationStr = v + cfg.DeviceCodeExpiration = config.ParseDuration(v, cfg.DeviceCodeExpiration) + } + if v, ok := all["device_code_polling_interval"]; ok { + if n, err := strconv.Atoi(v); err == nil { + cfg.DeviceCodePollingInterval = n + } + } + if v, ok := all["cors_allowed_origins"]; ok { cfg.CORSAllowedOrigins = nil cfg.CORSAllowAll = false diff --git a/pkg/cleanup/service.go b/pkg/cleanup/service.go index 02723155..cda443e2 100644 --- a/pkg/cleanup/service.go +++ b/pkg/cleanup/service.go @@ -31,6 +31,7 @@ var transientTables = []struct { {"sessions", `DELETE FROM sessions WHERE expires_at < ?`}, {"idp_sessions", `DELETE FROM idp_sessions WHERE deactivated_at IS NOT NULL AND deactivated_at < ?`}, {"password_reset_tokens", `DELETE FROM password_reset_tokens WHERE expires_at < ?`}, + {"device_codes", `DELETE FROM device_codes WHERE expires_at < ?`}, } func deleteExpiredTransientRows(threshold time.Time) { diff --git a/pkg/cli/start.go b/pkg/cli/start.go index 69ef02f1..8fc14abc 100644 --- a/pkg/cli/start.go +++ b/pkg/cli/start.go @@ -28,6 +28,7 @@ import ( "github.com/eugenioenko/autentico/pkg/db" "github.com/eugenioenko/autentico/pkg/db/migrations" "github.com/eugenioenko/autentico/pkg/deletion" + "github.com/eugenioenko/autentico/pkg/devicecode" "github.com/eugenioenko/autentico/pkg/emailverification" "github.com/eugenioenko/autentico/pkg/federation" "github.com/eugenioenko/autentico/pkg/group" @@ -154,6 +155,8 @@ func RunStart(c *cli.Context) error { mux.Handle(oauth+"/signup/", csrfProtected(signup.HandleSignup)) mux.Handle("POST "+oauth+"/token", rateLimitedFunc(token.HandleToken)) mux.Handle("POST "+oauth+"/protocol/openid-connect/token", rateLimitedFunc(token.HandleToken)) + mux.Handle("POST "+oauth+"/device_authorization", rateLimitedFunc(devicecode.HandleDeviceAuthorization)) + mux.Handle(oauth+"/device", rateLimited(csrfProtected(devicecode.HandleDeviceVerification))) mux.Handle("POST "+oauth+"/revoke", rateLimitedFunc(revoke.HandleRevoke)) mux.Handle("POST "+oauth+"/introspect", rateLimitedFunc(introspect.HandleIntrospect)) mux.Handle(oauth+"/userinfo", rateLimitedFunc(userinfo.HandleUserInfo)) diff --git a/pkg/client/model.go b/pkg/client/model.go index 722741cd..7493bc2b 100644 --- a/pkg/client/model.go +++ b/pkg/client/model.go @@ -224,7 +224,7 @@ func ValidateClientCreateRequest(input ClientCreateRequest) error { return validation.ValidateStruct(&input, validation.Field(&input.ClientName, validation.Required, validation.Length(1, 255), validation.Match(noHTMLPattern).Error("must not contain HTML characters (< or >)")), validation.Field(&input.RedirectURIs, validation.Required, validation.Length(1, 10)), - validation.Field(&input.GrantTypes, validation.Each(validation.In("authorization_code", "refresh_token", "client_credentials", "password"))), + validation.Field(&input.GrantTypes, validation.Each(validation.In("authorization_code", "refresh_token", "client_credentials", "password", "urn:ietf:params:oauth:grant-type:device_code"))), validation.Field(&input.ResponseTypes, validation.Each(validation.In("code", "token", "id_token"))), validation.Field(&input.ClientType, validation.In("", "confidential", "public")), validation.Field(&input.TokenEndpointAuthMethod, validation.In("", "client_secret_basic", "client_secret_post", "none")), @@ -246,7 +246,7 @@ func ValidateClientUpdateRequest(input ClientUpdateRequest) error { return validation.ValidateStruct(&input, validation.Field(&input.ClientName, validation.Length(0, 255), validation.Match(noHTMLPattern).Error("must not contain HTML characters (< or >)")), validation.Field(&input.RedirectURIs, validation.Length(0, 10)), - validation.Field(&input.GrantTypes, validation.Each(validation.In("authorization_code", "refresh_token", "client_credentials", "password"))), + validation.Field(&input.GrantTypes, validation.Each(validation.In("authorization_code", "refresh_token", "client_credentials", "password", "urn:ietf:params:oauth:grant-type:device_code"))), validation.Field(&input.ResponseTypes, validation.Each(validation.In("code", "token", "id_token"))), validation.Field(&input.TokenEndpointAuthMethod, validation.In("", "client_secret_basic", "client_secret_post", "none")), ) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6802a02d..99ecc534 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -130,6 +130,10 @@ type Config struct { AllowUsernameChange bool // When false (default), users cannot change their own email via the account portal. AllowEmailChange bool + // Device Authorization Grant (RFC 8628) + DeviceCodeExpiration time.Duration + DeviceCodeExpirationStr string + DeviceCodePollingInterval int // CORS: parsed from the "cors_allowed_origins" runtime setting. CORSAllowedOrigins []string CORSAllowAll bool @@ -192,6 +196,9 @@ var defaultConfig = Config{ ValidationMaxUsernameLength: 64, ValidationMinPasswordLength: 6, ValidationMaxPasswordLength: 64, + DeviceCodeExpiration: 10 * time.Minute, + DeviceCodeExpirationStr: "10m", + DeviceCodePollingInterval: 5, Theme: ThemeConfig{Title: "Autentico"}, AllowSelfServiceDeletion: false, AllowUsernameChange: false, diff --git a/pkg/db/migrations/008_device_codes.go b/pkg/db/migrations/008_device_codes.go new file mode 100644 index 00000000..634746b4 --- /dev/null +++ b/pkg/db/migrations/008_device_codes.go @@ -0,0 +1,20 @@ +package migrations + +const migration008 = ` +CREATE TABLE IF NOT EXISTS device_codes ( + code TEXT PRIMARY KEY, + user_code TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT '', + expires_at DATETIME NOT NULL, + interval_seconds INTEGER NOT NULL DEFAULT 5, + user_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + last_polled_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_device_codes_user_code ON device_codes(user_code); +CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at ON device_codes(expires_at); +` diff --git a/pkg/db/migrations/migrations.go b/pkg/db/migrations/migrations.go index 9ac48246..e36f7e69 100644 --- a/pkg/db/migrations/migrations.go +++ b/pkg/db/migrations/migrations.go @@ -7,7 +7,7 @@ import ( // SchemaVersion is the schema version this binary expects. // Increment this and add a new Migration entry each time the schema changes. -var SchemaVersion = 7 +var SchemaVersion = 8 // Migration represents a single schema change. type Migration struct { @@ -24,6 +24,7 @@ var migrations = []Migration{ {Version: 5, SQL: migration005}, {Version: 6, SQL: migration006}, {Version: 7, SQL: migration007}, + {Version: 8, SQL: migration008}, } func getUserVersion(db *sql.DB) (int, error) { diff --git a/pkg/devicecode/create.go b/pkg/devicecode/create.go new file mode 100644 index 00000000..9a16e614 --- /dev/null +++ b/pkg/devicecode/create.go @@ -0,0 +1,12 @@ +package devicecode + +import "github.com/eugenioenko/autentico/pkg/db" + +func CreateDeviceCode(dc DeviceCode) error { + _, err := db.GetDB().Exec( + `INSERT INTO device_codes (code, user_code, client_id, scope, expires_at, interval_seconds, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + dc.Code, dc.UserCode, dc.ClientID, dc.Scope, dc.ExpiresAt, dc.IntervalSeconds, dc.Status, + ) + return err +} diff --git a/pkg/devicecode/devicecode_test.go b/pkg/devicecode/devicecode_test.go new file mode 100644 index 00000000..0a7f091d --- /dev/null +++ b/pkg/devicecode/devicecode_test.go @@ -0,0 +1,154 @@ +package devicecode + +import ( + "testing" + "time" + + "github.com/eugenioenko/autentico/pkg/user" + testutils "github.com/eugenioenko/autentico/tests/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAndReadDeviceCode(t *testing.T) { + testutils.WithTestDB(t) + + dc := DeviceCode{ + Code: "test-device-code-123", + UserCode: "BCDFGHJK", + ClientID: "test-client", + Scope: "openid profile", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + + err := CreateDeviceCode(dc) + require.NoError(t, err) + + // Read by code + found, err := DeviceCodeByCode("test-device-code-123") + require.NoError(t, err) + assert.Equal(t, "BCDFGHJK", found.UserCode) + assert.Equal(t, "test-client", found.ClientID) + assert.Equal(t, "openid profile", found.Scope) + assert.Equal(t, "pending", found.Status) + assert.Equal(t, 5, found.IntervalSeconds) + + // Read by user code + found2, err := DeviceCodeByUserCode("BCDFGHJK") + require.NoError(t, err) + assert.Equal(t, "test-device-code-123", found2.Code) +} + +func TestDeviceCodeByCode_NotFound(t *testing.T) { + testutils.WithTestDB(t) + + _, err := DeviceCodeByCode("nonexistent") + assert.Error(t, err) +} + +func TestDeviceCodeByUserCode_NotFound(t *testing.T) { + testutils.WithTestDB(t) + + _, err := DeviceCodeByUserCode("ZZZZZZZZ") + assert.Error(t, err) +} + +func TestAuthorizeDeviceCode(t *testing.T) { + testutils.WithTestDB(t) + + usr, err := user.CreateUser("deviceuser", "password123", "device@test.com") + require.NoError(t, err) + + dc := DeviceCode{ + Code: "auth-test-code", + UserCode: "LMNPQRST", + ClientID: "test-client", + Scope: "openid", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, CreateDeviceCode(dc)) + + err = AuthorizeDeviceCode("LMNPQRST", usr.ID) + require.NoError(t, err) + + found, err := DeviceCodeByUserCode("LMNPQRST") + require.NoError(t, err) + assert.Equal(t, "authorized", found.Status) + assert.NotNil(t, found.UserID) + assert.Equal(t, usr.ID, *found.UserID) +} + +func TestDenyDeviceCode(t *testing.T) { + testutils.WithTestDB(t) + + dc := DeviceCode{ + Code: "deny-test-code", + UserCode: "VWXZBCDF", + ClientID: "test-client", + Scope: "openid", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, CreateDeviceCode(dc)) + + err := DenyDeviceCode("VWXZBCDF") + require.NoError(t, err) + + found, err := DeviceCodeByUserCode("VWXZBCDF") + require.NoError(t, err) + assert.Equal(t, "denied", found.Status) +} + +func TestAuthorizeDeviceCode_NotPending(t *testing.T) { + testutils.WithTestDB(t) + + dc := DeviceCode{ + Code: "already-denied-code", + UserCode: "GHJKLMNP", + ClientID: "test-client", + Scope: "openid", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, CreateDeviceCode(dc)) + + // Deny it first + require.NoError(t, DenyDeviceCode("GHJKLMNP")) + + // Try to authorize — should not change (WHERE status = 'pending') + err := AuthorizeDeviceCode("GHJKLMNP", "user-456") + require.NoError(t, err) // no SQL error, but 0 rows affected + + found, err := DeviceCodeByUserCode("GHJKLMNP") + require.NoError(t, err) + assert.Equal(t, "denied", found.Status) +} + +func TestUpdateLastPolledAt(t *testing.T) { + testutils.WithTestDB(t) + + dc := DeviceCode{ + Code: "poll-test-code", + UserCode: "QRSTVWXZ", + ClientID: "test-client", + Scope: "openid", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, CreateDeviceCode(dc)) + + now := time.Now() + err := UpdateLastPolledAt("poll-test-code", now) + require.NoError(t, err) + + found, err := DeviceCodeByCode("poll-test-code") + require.NoError(t, err) + assert.NotNil(t, found.LastPolledAt) +} diff --git a/pkg/devicecode/handler.go b/pkg/devicecode/handler.go new file mode 100644 index 00000000..05eee928 --- /dev/null +++ b/pkg/devicecode/handler.go @@ -0,0 +1,294 @@ +package devicecode + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/eugenioenko/autentico/pkg/client" + "github.com/eugenioenko/autentico/pkg/config" + "github.com/eugenioenko/autentico/pkg/idpsession" + "github.com/eugenioenko/autentico/pkg/reqid" + "github.com/eugenioenko/autentico/pkg/utils" + "github.com/eugenioenko/autentico/view" + "github.com/gorilla/csrf" +) + +// getAuthenticatedUserID extracts the currently logged-in user from the IdP session cookie. +func getAuthenticatedUserID(r *http.Request) string { + sessionID := idpsession.ReadCookie(r) + if sessionID == "" { + return "" + } + sess, err := idpsession.IdpSessionByID(sessionID) + if err != nil || sess == nil { + return "" + } + return sess.UserID +} + +const DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// HandleDeviceAuthorization handles the device authorization request. +// @Summary Device Authorization +// @Description Issues a device code and user code for device authorization flow (RFC 8628) +// @Tags oauth2 +// @Accept application/x-www-form-urlencoded +// @Produce json +// @Param client_id formData string true "Client ID" +// @Param scope formData string false "Requested scope" +// @Success 200 {object} DeviceAuthorizationResponse +// @Failure 400 {object} model.AuthErrorResponse +// @Router /oauth2/device_authorization [post] +func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.WriteErrorResponse(w, http.StatusMethodNotAllowed, "invalid_request", "Only POST method is allowed") + return + } + + if err := r.ParseForm(); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid form data") + return + } + + clientID := r.FormValue("client_id") + scope := r.FormValue("scope") + + if clientID == "" { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "client_id is required") + return + } + + // RFC 8628 §3.1: validate client exists and supports device_code grant + registeredClient, err := client.ClientByClientID(clientID) + if err != nil { + slog.Warn("device_authorization: unknown client_id", "request_id", reqid.Get(r.Context()), "client_id", clientID) + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_client", "Unknown client_id") + return + } + + if !client.IsGrantTypeAllowed(registeredClient, DeviceCodeGrantType) { + slog.Warn("device_authorization: grant type not allowed", "request_id", reqid.Get(r.Context()), "client_id", clientID) + utils.WriteErrorResponse(w, http.StatusBadRequest, "unauthorized_client", "Client is not authorized for device_code grant") + return + } + + // Validate scope against client's allowed scopes + if scope != "" && !client.ValidateScopes(registeredClient, scope) { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_scope", "One or more requested scopes are not allowed for this client") + return + } + if scope == "" && registeredClient.Scopes != "" { + scope = registeredClient.Scopes + } + + deviceCode, err := GenerateDeviceCode() + if err != nil { + slog.Error("device_authorization: failed to generate device_code", "error", err) + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Failed to generate device code") + return + } + + userCode, err := GenerateUserCode() + if err != nil { + slog.Error("device_authorization: failed to generate user_code", "error", err) + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Failed to generate user code") + return + } + + cfg := config.Get() + dc := DeviceCode{ + Code: deviceCode, + UserCode: userCode, + ClientID: clientID, + Scope: scope, + ExpiresAt: time.Now().Add(cfg.DeviceCodeExpiration), + IntervalSeconds: cfg.DeviceCodePollingInterval, + Status: "pending", + } + + if err := CreateDeviceCode(dc); err != nil { + slog.Error("device_authorization: failed to store device code", "error", err) + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Failed to create device code") + return + } + + bs := config.GetBootstrap() + verificationURI := fmt.Sprintf("%s%s/device", bs.AppURL, bs.AppOAuthPath) + + // RFC 8628 §3.2: Device Authorization Response + resp := DeviceAuthorizationResponse{ + DeviceCode: deviceCode, + UserCode: FormatUserCode(userCode), + VerificationURI: verificationURI, + VerificationURIComplete: fmt.Sprintf("%s?user_code=%s", verificationURI, FormatUserCode(userCode)), + ExpiresIn: int(cfg.DeviceCodeExpiration.Seconds()), + Interval: cfg.DeviceCodePollingInterval, + } + + w.Header().Set("Cache-Control", "no-store") + utils.WriteApiResponse(w, resp, http.StatusOK) +} + +// HandleDeviceVerification renders the device verification page and handles form submission. +func HandleDeviceVerification(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleDeviceVerifyGet(w, r) + case http.MethodPost: + handleDeviceVerifyPost(w, r) + default: + view.RenderError(w, r, http.StatusMethodNotAllowed, "Method not allowed.") + } +} + +func handleDeviceVerifyGet(w http.ResponseWriter, r *http.Request) { + userCode := r.URL.Query().Get("user_code") + renderDeviceVerifyPage(w, r, userCode, "") +} + +func handleDeviceVerifyPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + renderDeviceVerifyPage(w, r, "", "Invalid form data") + return + } + + action := r.FormValue("action") + userCode := NormalizeUserCode(r.FormValue("user_code")) + + // Check if user is authenticated via IdP session + userID := getAuthenticatedUserID(r) + if userID == "" { + // Not authenticated — redirect to login with return URL + bs := config.GetBootstrap() + returnURL := fmt.Sprintf("%s%s/device?user_code=%s", bs.AppURL, bs.AppOAuthPath, FormatUserCode(userCode)) + loginURL := fmt.Sprintf("%s%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&prompt=login&device_return=%s", + bs.AppURL, bs.AppOAuthPath, config.AdminClientID, bs.AppURL, returnURL) + http.Redirect(w, r, loginURL, http.StatusFound) + return + } + + // Lookup the device code + dc, err := DeviceCodeByUserCode(userCode) + if err != nil || dc == nil { + renderDeviceVerifyPage(w, r, "", "Invalid or expired code. Please check and try again.") + return + } + + if time.Now().After(dc.ExpiresAt) { + renderDeviceVerifyPage(w, r, "", "This code has expired. Please request a new one from your device.") + return + } + + if dc.Status != "pending" { + renderDeviceVerifyPage(w, r, "", "This code has already been used.") + return + } + + // If no action yet, show the confirmation page + if action == "" || action == "verify" { + registeredClient, _ := client.ClientByClientID(dc.ClientID) + clientName := dc.ClientID + if registeredClient != nil { + clientName = registeredClient.ClientName + } + renderDeviceConfirmPage(w, r, userCode, clientName, dc.Scope) + return + } + + // Handle authorize/deny + switch action { + case "allow": + if err := AuthorizeDeviceCode(userCode, userID); err != nil { + slog.Error("device_verify: failed to authorize", "error", err) + renderDeviceVerifyPage(w, r, "", "Something went wrong. Please try again.") + return + } + renderDeviceSuccessPage(w, r) + case "deny": + if err := DenyDeviceCode(userCode); err != nil { + slog.Error("device_verify: failed to deny", "error", err) + renderDeviceVerifyPage(w, r, "", "Something went wrong. Please try again.") + return + } + renderDeviceDeniedPage(w, r) + default: + renderDeviceVerifyPage(w, r, userCode, "Invalid action.") + } +} + +func renderDeviceVerifyPage(w http.ResponseWriter, r *http.Request, userCode string, errMsg string) { + cfg := config.Get() + tmpl, err := view.ParseTemplate("device_verify") + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data := map[string]any{ + "UserCode": FormatUserCode(NormalizeUserCode(userCode)), + "Error": errMsg, + "ThemeTitle": cfg.Theme.Title, + "ThemeLogoUrl": cfg.Theme.LogoUrl, + csrf.TemplateTag: csrf.TemplateField(r), + } + view.InjectNonce(r, data) + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, "Template Execution Error", http.StatusInternalServerError) + } +} + +func renderDeviceConfirmPage(w http.ResponseWriter, r *http.Request, userCode string, clientName string, scope string) { + cfg := config.Get() + tmpl, err := view.ParseTemplate("device_confirm") + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data := map[string]any{ + "UserCode": FormatUserCode(userCode), + "ClientName": clientName, + "Scope": scope, + "ThemeTitle": cfg.Theme.Title, + "ThemeLogoUrl": cfg.Theme.LogoUrl, + csrf.TemplateTag: csrf.TemplateField(r), + } + view.InjectNonce(r, data) + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, "Template Execution Error", http.StatusInternalServerError) + } +} + +func renderDeviceSuccessPage(w http.ResponseWriter, r *http.Request) { + cfg := config.Get() + tmpl, err := view.ParseTemplate("device_success") + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data := map[string]any{ + "ThemeTitle": cfg.Theme.Title, + "ThemeLogoUrl": cfg.Theme.LogoUrl, + } + view.InjectNonce(r, data) + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, "Template Execution Error", http.StatusInternalServerError) + } +} + +func renderDeviceDeniedPage(w http.ResponseWriter, r *http.Request) { + cfg := config.Get() + tmpl, err := view.ParseTemplate("device_denied") + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + data := map[string]any{ + "ThemeTitle": cfg.Theme.Title, + "ThemeLogoUrl": cfg.Theme.LogoUrl, + } + view.InjectNonce(r, data) + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, "Template Execution Error", http.StatusInternalServerError) + } +} diff --git a/pkg/devicecode/handler_test.go b/pkg/devicecode/handler_test.go new file mode 100644 index 00000000..5a19c47a --- /dev/null +++ b/pkg/devicecode/handler_test.go @@ -0,0 +1,157 @@ +package devicecode + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/eugenioenko/autentico/pkg/client" + "github.com/eugenioenko/autentico/pkg/config" + testutils "github.com/eugenioenko/autentico/tests/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createTestClient(t *testing.T) { + t.Helper() + _, err := client.CreateClientWithID("device-test-client", client.ClientCreateRequest{ + ClientName: "Device Test Client", + ClientType: "public", + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:device_code"}, + ResponseTypes: []string{"code"}, + Scopes: "openid profile email", + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) +} + +func TestHandleDeviceAuthorization_Success(t *testing.T) { + testutils.WithTestDB(t) + testutils.WithConfigOverride(t, func() { + config.Values.DeviceCodeExpiration = 10 * time.Minute + config.Values.DeviceCodePollingInterval = 5 + }) + createTestClient(t) + + form := url.Values{} + form.Set("client_id", "device-test-client") + form.Set("scope", "openid profile") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device_authorization", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp DeviceAuthorizationResponse + err := json.Unmarshal(rr.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.NotEmpty(t, resp.DeviceCode) + assert.NotEmpty(t, resp.UserCode) + assert.Contains(t, resp.UserCode, "-") + assert.Equal(t, 600, resp.ExpiresIn) + assert.Equal(t, 5, resp.Interval) + assert.Contains(t, resp.VerificationURI, "/device") + assert.Contains(t, resp.VerificationURIComplete, "user_code=") +} + +func TestHandleDeviceAuthorization_MissingClientID(t *testing.T) { + testutils.WithTestDB(t) + + form := url.Values{} + form.Set("scope", "openid") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device_authorization", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "client_id is required") +} + +func TestHandleDeviceAuthorization_UnknownClient(t *testing.T) { + testutils.WithTestDB(t) + + form := url.Values{} + form.Set("client_id", "nonexistent-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device_authorization", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid_client") +} + +func TestHandleDeviceAuthorization_GrantTypeNotAllowed(t *testing.T) { + testutils.WithTestDB(t) + + // Create client without device_code grant + _, err := client.CreateClientWithID("no-device-client", client.ClientCreateRequest{ + ClientName: "No Device Client", + ClientType: "public", + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + Scopes: "openid", + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) + + form := url.Values{} + form.Set("client_id", "no-device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device_authorization", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "unauthorized_client") +} + +func TestHandleDeviceAuthorization_WrongMethod(t *testing.T) { + testutils.WithTestDB(t) + + req := httptest.NewRequest(http.MethodGet, "/oauth2/device_authorization", nil) + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} + +func TestHandleDeviceAuthorization_InvalidScope(t *testing.T) { + testutils.WithTestDB(t) + testutils.WithConfigOverride(t, func() { + config.Values.DeviceCodeExpiration = 10 * time.Minute + config.Values.DeviceCodePollingInterval = 5 + }) + createTestClient(t) + + form := url.Values{} + form.Set("client_id", "device-test-client") + form.Set("scope", "openid admin_super_scope") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device_authorization", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleDeviceAuthorization(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "invalid_scope") +} diff --git a/pkg/devicecode/model.go b/pkg/devicecode/model.go new file mode 100644 index 00000000..d489dbe0 --- /dev/null +++ b/pkg/devicecode/model.go @@ -0,0 +1,27 @@ +package devicecode + +import "time" + +// DeviceCode represents a device authorization request in the database. +type DeviceCode struct { + Code string `db:"code"` + UserCode string `db:"user_code"` + ClientID string `db:"client_id"` + Scope string `db:"scope"` + ExpiresAt time.Time `db:"expires_at"` + IntervalSeconds int `db:"interval_seconds"` + UserID *string `db:"user_id"` + Status string `db:"status"` + LastPolledAt *time.Time `db:"last_polled_at"` + CreatedAt time.Time `db:"created_at"` +} + +// RFC 8628 §3.2: Device Authorization Response +type DeviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval,omitempty"` +} diff --git a/pkg/devicecode/read.go b/pkg/devicecode/read.go new file mode 100644 index 00000000..f9fbf455 --- /dev/null +++ b/pkg/devicecode/read.go @@ -0,0 +1,27 @@ +package devicecode + +import "github.com/eugenioenko/autentico/pkg/db" + +func DeviceCodeByCode(code string) (*DeviceCode, error) { + var dc DeviceCode + err := db.GetDB().QueryRow( + `SELECT code, user_code, client_id, scope, expires_at, interval_seconds, user_id, status, last_polled_at, created_at + FROM device_codes WHERE code = ?`, code, + ).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &dc.Scope, &dc.ExpiresAt, &dc.IntervalSeconds, &dc.UserID, &dc.Status, &dc.LastPolledAt, &dc.CreatedAt) + if err != nil { + return nil, err + } + return &dc, nil +} + +func DeviceCodeByUserCode(userCode string) (*DeviceCode, error) { + var dc DeviceCode + err := db.GetDB().QueryRow( + `SELECT code, user_code, client_id, scope, expires_at, interval_seconds, user_id, status, last_polled_at, created_at + FROM device_codes WHERE user_code = ?`, userCode, + ).Scan(&dc.Code, &dc.UserCode, &dc.ClientID, &dc.Scope, &dc.ExpiresAt, &dc.IntervalSeconds, &dc.UserID, &dc.Status, &dc.LastPolledAt, &dc.CreatedAt) + if err != nil { + return nil, err + } + return &dc, nil +} diff --git a/pkg/devicecode/service.go b/pkg/devicecode/service.go new file mode 100644 index 00000000..0977cfcb --- /dev/null +++ b/pkg/devicecode/service.go @@ -0,0 +1,51 @@ +package devicecode + +import ( + "crypto/rand" + "encoding/hex" + "math/big" + "strings" +) + +// RFC 8628 §6.1: device_code must have at least 160 bits of entropy. +func GenerateDeviceCode() (string, error) { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// userCodeAlphabet uses consonants only to avoid generating recognizable words. +const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ" + +// GenerateUserCode generates an 8-character user code from the consonant alphabet. +// Displayed with a hyphen for readability (e.g., "WDJB-MJHT"). +func GenerateUserCode() (string, error) { + max := big.NewInt(int64(len(userCodeAlphabet))) + var sb strings.Builder + for i := 0; i < 8; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + sb.WriteByte(userCodeAlphabet[n.Int64()]) + } + return sb.String(), nil +} + +// FormatUserCode formats an 8-char code with a hyphen: "ABCDEFGH" -> "ABCD-EFGH" +func FormatUserCode(code string) string { + if len(code) != 8 { + return code + } + return code[:4] + "-" + code[4:] +} + +// NormalizeUserCode strips hyphens/spaces and uppercases for comparison. +func NormalizeUserCode(input string) string { + input = strings.ToUpper(input) + input = strings.ReplaceAll(input, "-", "") + input = strings.ReplaceAll(input, " ", "") + return input +} diff --git a/pkg/devicecode/service_test.go b/pkg/devicecode/service_test.go new file mode 100644 index 00000000..ac538ed7 --- /dev/null +++ b/pkg/devicecode/service_test.go @@ -0,0 +1,54 @@ +package devicecode + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateDeviceCode(t *testing.T) { + code, err := GenerateDeviceCode() + require.NoError(t, err) + // 20 bytes = 40 hex chars + assert.Len(t, code, 40) + + // Ensure uniqueness + code2, err := GenerateDeviceCode() + require.NoError(t, err) + assert.NotEqual(t, code, code2) +} + +func TestGenerateUserCode(t *testing.T) { + code, err := GenerateUserCode() + require.NoError(t, err) + assert.Len(t, code, 8) + + // All characters should be from the consonant alphabet + for _, ch := range code { + assert.Contains(t, userCodeAlphabet, string(ch)) + } +} + +func TestGenerateUserCode_NoVowels(t *testing.T) { + // Generate many codes and verify no vowels appear + for range 100 { + code, err := GenerateUserCode() + require.NoError(t, err) + for _, ch := range code { + assert.NotContains(t, "AEIOU", string(ch)) + } + } +} + +func TestFormatUserCode(t *testing.T) { + assert.Equal(t, "WDJB-MJHT", FormatUserCode("WDJBMJHT")) + assert.Equal(t, "SHORT", FormatUserCode("SHORT")) +} + +func TestNormalizeUserCode(t *testing.T) { + assert.Equal(t, "WDJBMJHT", NormalizeUserCode("WDJB-MJHT")) + assert.Equal(t, "WDJBMJHT", NormalizeUserCode("wdjb-mjht")) + assert.Equal(t, "WDJBMJHT", NormalizeUserCode("wdjb mjht")) + assert.Equal(t, "WDJBMJHT", NormalizeUserCode(" WDJB MJHT ")) +} diff --git a/pkg/devicecode/update.go b/pkg/devicecode/update.go new file mode 100644 index 00000000..fead3992 --- /dev/null +++ b/pkg/devicecode/update.go @@ -0,0 +1,31 @@ +package devicecode + +import ( + "time" + + "github.com/eugenioenko/autentico/pkg/db" +) + +func AuthorizeDeviceCode(userCode string, userID string) error { + _, err := db.GetDB().Exec( + `UPDATE device_codes SET status = 'authorized', user_id = ? WHERE user_code = ? AND status = 'pending'`, + userID, userCode, + ) + return err +} + +func DenyDeviceCode(userCode string) error { + _, err := db.GetDB().Exec( + `UPDATE device_codes SET status = 'denied' WHERE user_code = ? AND status = 'pending'`, + userCode, + ) + return err +} + +func UpdateLastPolledAt(code string, t time.Time) error { + _, err := db.GetDB().Exec( + `UPDATE device_codes SET last_polled_at = ? WHERE code = ?`, + t, code, + ) + return err +} diff --git a/pkg/model/well_known_config.go b/pkg/model/well_known_config.go index 9a83f648..7ea05bec 100644 --- a/pkg/model/well_known_config.go +++ b/pkg/model/well_known_config.go @@ -47,4 +47,6 @@ type WellKnownConfigResponse struct { CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` // OIDC Discovery §3: OPTIONAL. Prompt values supported. PromptValuesSupported []string `json:"prompt_values_supported,omitempty"` + // RFC 8628 §4: device_authorization_endpoint. + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` } diff --git a/pkg/token/device_code.go b/pkg/token/device_code.go new file mode 100644 index 00000000..27da4f5d --- /dev/null +++ b/pkg/token/device_code.go @@ -0,0 +1,78 @@ +package token + +import ( + "log/slog" + "net/http" + "time" + + "github.com/eugenioenko/autentico/pkg/devicecode" + "github.com/eugenioenko/autentico/pkg/reqid" + "github.com/eugenioenko/autentico/pkg/user" + "github.com/eugenioenko/autentico/pkg/utils" +) + +// handleDeviceCodeGrant implements the token polling side of RFC 8628 §3.4-3.5. +func handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request, request TokenRequest) (*user.User, string, error) { + if request.DeviceCode == "" { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "device_code is required") + return nil, "", errGrantHandled + } + + dc, err := devicecode.DeviceCodeByCode(request.DeviceCode) + if err != nil { + slog.Warn("token: device_code not found", "request_id", reqid.Get(r.Context())) + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "Invalid device_code") + return nil, "", errGrantHandled + } + + // RFC 8628 §3.5: expired_token + if time.Now().After(dc.ExpiresAt) { + utils.WriteErrorResponse(w, http.StatusBadRequest, "expired_token", "The device code has expired") + return nil, "", errGrantHandled + } + + // RFC 8628 §3.5: slow_down — enforce polling interval + if dc.LastPolledAt != nil { + elapsed := time.Since(*dc.LastPolledAt) + if elapsed < time.Duration(dc.IntervalSeconds)*time.Second { + // RFC 8628 §3.5: slow_down adds 5 seconds to the interval + _ = devicecode.UpdateLastPolledAt(dc.Code, time.Now()) + utils.WriteErrorResponse(w, http.StatusBadRequest, "slow_down", "Polling too frequently") + return nil, "", errGrantHandled + } + } + _ = devicecode.UpdateLastPolledAt(dc.Code, time.Now()) + + switch dc.Status { + case "pending": + // RFC 8628 §3.5: authorization_pending + utils.WriteErrorResponse(w, http.StatusBadRequest, "authorization_pending", "The user has not yet authorized this device") + return nil, "", errGrantHandled + case "denied": + // RFC 8628 §3.5: access_denied + utils.WriteErrorResponse(w, http.StatusBadRequest, "access_denied", "The user denied the authorization request") + return nil, "", errGrantHandled + case "authorized": + if dc.UserID == nil { + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Device code authorized but no user associated") + return nil, "", errGrantHandled + } + usr, err := user.UserByID(*dc.UserID) + if err != nil { + slog.Error("token: device_code user not found", "request_id", reqid.Get(r.Context()), "user_id", *dc.UserID) + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "User not found") + return nil, "", errGrantHandled + } + return usr, dc.Scope, nil + default: + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "Invalid device code status") + return nil, "", errGrantHandled + } +} + +// errGrantHandled is a sentinel error indicating the grant handler already wrote an HTTP response. +var errGrantHandled = &grantError{} + +type grantError struct{} + +func (e *grantError) Error() string { return "grant handled" } diff --git a/pkg/token/device_code_test.go b/pkg/token/device_code_test.go new file mode 100644 index 00000000..19b34ad9 --- /dev/null +++ b/pkg/token/device_code_test.go @@ -0,0 +1,198 @@ +package token + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/eugenioenko/autentico/pkg/client" + "github.com/eugenioenko/autentico/pkg/config" + "github.com/eugenioenko/autentico/pkg/devicecode" + "github.com/eugenioenko/autentico/pkg/user" + testutils "github.com/eugenioenko/autentico/tests/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupDeviceCodeTest(t *testing.T) (string, string) { + t.Helper() + testutils.WithTestDB(t) + testutils.WithConfigOverride(t, func() { + config.Values.DeviceCodeExpiration = 10 * time.Minute + config.Values.DeviceCodePollingInterval = 5 + }) + + _, err := client.CreateClientWithID("device-client", client.ClientCreateRequest{ + ClientName: "Device Client", + ClientType: "public", + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:device_code"}, + ResponseTypes: []string{"code"}, + Scopes: "openid profile email", + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) + + usr, err := user.CreateUser("deviceuser", "password123", "device@test.com") + require.NoError(t, err) + + dc := devicecode.DeviceCode{ + Code: "test-device-code-poll", + UserCode: "BCDFGHJK", + ClientID: "device-client", + Scope: "openid profile", + ExpiresAt: time.Now().Add(10 * time.Minute), + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, devicecode.CreateDeviceCode(dc)) + + return usr.ID, dc.Code +} + +func TestDeviceCodeGrant_AuthorizationPending(t *testing.T) { + _, code := setupDeviceCodeTest(t) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", code) + form.Set("client_id", "device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleToken(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "authorization_pending", resp["error"]) +} + +func TestDeviceCodeGrant_AccessDenied(t *testing.T) { + _, code := setupDeviceCodeTest(t) + require.NoError(t, devicecode.DenyDeviceCode("BCDFGHJK")) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", code) + form.Set("client_id", "device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleToken(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "access_denied", resp["error"]) +} + +func TestDeviceCodeGrant_ExpiredToken(t *testing.T) { + testutils.WithTestDB(t) + testutils.WithConfigOverride(t, func() { + config.Values.DeviceCodeExpiration = 10 * time.Minute + config.Values.DeviceCodePollingInterval = 5 + }) + + _, err := client.CreateClientWithID("device-client", client.ClientCreateRequest{ + ClientName: "Device Client", + ClientType: "public", + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:device_code"}, + ResponseTypes: []string{"code"}, + Scopes: "openid profile email", + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) + + dc := devicecode.DeviceCode{ + Code: "expired-device-code", + UserCode: "LMNPQRST", + ClientID: "device-client", + Scope: "openid", + ExpiresAt: time.Now().Add(-1 * time.Minute), // already expired + IntervalSeconds: 5, + Status: "pending", + } + require.NoError(t, devicecode.CreateDeviceCode(dc)) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", "expired-device-code") + form.Set("client_id", "device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleToken(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "expired_token", resp["error"]) +} + +func TestDeviceCodeGrant_Success(t *testing.T) { + userID, code := setupDeviceCodeTest(t) + require.NoError(t, devicecode.AuthorizeDeviceCode("BCDFGHJK", userID)) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", code) + form.Set("client_id", "device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleToken(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp TokenResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) + assert.Equal(t, "Bearer", resp.TokenType) + assert.Equal(t, "openid profile", resp.Scope) +} + +func TestDeviceCodeGrant_MissingDeviceCode(t *testing.T) { + testutils.WithTestDB(t) + + _, err := client.CreateClientWithID("device-client", client.ClientCreateRequest{ + ClientName: "Device Client", + ClientType: "public", + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:device_code"}, + ResponseTypes: []string{"code"}, + Scopes: "openid profile email", + TokenEndpointAuthMethod: "none", + }) + require.NoError(t, err) + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("client_id", "device-client") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + HandleToken(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "device_code is required") +} diff --git a/pkg/token/handler.go b/pkg/token/handler.go index 1e7b343f..753606ef 100644 --- a/pkg/token/handler.go +++ b/pkg/token/handler.go @@ -70,6 +70,7 @@ func HandleToken(w http.ResponseWriter, r *http.Request) { TotpCode: r.FormValue("totp_code"), RefreshToken: r.FormValue("refresh_token"), Scope: r.FormValue("scope"), + DeviceCode: r.FormValue("device_code"), } err = ValidateTokenRequest(request) @@ -251,6 +252,12 @@ func HandleToken(w http.ResponseWriter, r *http.Request) { utils.WriteApiResponse(w, ccResponse, http.StatusOK) return + case "urn:ietf:params:oauth:grant-type:device_code": + usr, codeScope, err = handleDeviceCodeGrant(w, r, request) + if err != nil { + return + } + case "refresh_token": usr, err = UserByRefreshToken(w, request) if err != nil { diff --git a/pkg/token/model.go b/pkg/token/model.go index d5a700f0..f5b53a78 100644 --- a/pkg/token/model.go +++ b/pkg/token/model.go @@ -36,6 +36,7 @@ type TokenRequest struct { TotpCode string `json:"totp_code,omitempty"` // The TOTP code for MFA verification (used in password grant type) RefreshToken string `json:"refresh_token,omitempty"` // The refresh token (used in refresh token grant type) Scope string `json:"scope,omitempty"` // The requested scope (used in password grant type) + DeviceCode string `json:"device_code,omitempty"` // The device code (used in device_code grant type, RFC 8628) } type RefreshTokenClaims struct { @@ -55,7 +56,7 @@ func (r *RefreshTokenClaims) Valid() error { func ValidateTokenRequest(input TokenRequest) error { return validation.ValidateStruct(&input, - validation.Field(&input.GrantType, validation.Required, validation.In("authorization_code", "refresh_token", "password", "client_credentials")), + validation.Field(&input.GrantType, validation.Required, validation.In("authorization_code", "refresh_token", "password", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code")), ) } diff --git a/pkg/wellknown/handler.go b/pkg/wellknown/handler.go index 3566c2f4..d0e9cf7c 100644 --- a/pkg/wellknown/handler.go +++ b/pkg/wellknown/handler.go @@ -64,7 +64,7 @@ func HandleWellKnownConfig(w http.ResponseWriter, r *http.Request) { }, // RFC 8414 §2: OPTIONAL (default: ["authorization_code", "implicit"]). // We explicitly list supported types to override the default. - GrantTypesSupported: []string{"authorization_code", "refresh_token", "password", "client_credentials"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token", "password", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code"}, AcrValuesSupported: []string{"1"}, RequestParameterSupported: false, // OIDC Core §6: request objects not supported // RFC 8414 §2: OPTIONAL endpoint metadata @@ -74,6 +74,7 @@ func HandleWellKnownConfig(w http.ResponseWriter, r *http.Request) { RevocationEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, CodeChallengeMethodsSupported: []string{"S256"}, // RFC 7636 §6.2 PromptValuesSupported: []string{"none", "login", "create"}, + DeviceAuthorizationEndpoint: fmt.Sprintf("%s/device_authorization", issuer), } utils.WriteApiResponse(w, response, http.StatusOK) diff --git a/tests/security/security_oauth2_test.go b/tests/security/security_oauth2_test.go index 98f96af9..d99e48b0 100644 --- a/tests/security/security_oauth2_test.go +++ b/tests/security/security_oauth2_test.go @@ -156,7 +156,6 @@ func TestToken_UnsupportedGrantType(t *testing.T) { grantTypes := []string{ "implicit", - "urn:ietf:params:oauth:grant-type:device_code", "custom_grant", "' OR 1=1--", "", diff --git a/view/device_confirm.html b/view/device_confirm.html new file mode 100644 index 00000000..397f84de --- /dev/null +++ b/view/device_confirm.html @@ -0,0 +1,16 @@ +{{define "title"}}{{.ThemeTitle}} - Authorize Device{{end}} + +{{define "content"}} +
+ {{template "logo" .}} +

Authorize Device

+

{{.ClientName}} is requesting access to your account

+ {{ .csrfField }} + + {{if .Scope}} +

Requested scopes: {{.Scope}}

+ {{end}} + + +
+{{end}} diff --git a/view/device_denied.html b/view/device_denied.html new file mode 100644 index 00000000..b051c2b2 --- /dev/null +++ b/view/device_denied.html @@ -0,0 +1,9 @@ +{{define "title"}}{{.ThemeTitle}} - Access Denied{{end}} + +{{define "content"}} +
+ {{template "logo" .}} +

Access Denied

+

You have denied access to the device. You can close this window.

+
+{{end}} diff --git a/view/device_success.html b/view/device_success.html new file mode 100644 index 00000000..735f07ff --- /dev/null +++ b/view/device_success.html @@ -0,0 +1,9 @@ +{{define "title"}}{{.ThemeTitle}} - Device Authorized{{end}} + +{{define "content"}} +
+ {{template "logo" .}} +

Device Authorized

+

You have successfully authorized the device. You can close this window.

+
+{{end}} diff --git a/view/device_verify.html b/view/device_verify.html new file mode 100644 index 00000000..94ef5cca --- /dev/null +++ b/view/device_verify.html @@ -0,0 +1,15 @@ +{{define "title"}}{{.ThemeTitle}} - Device Verification{{end}} + +{{define "content"}} +
+ {{template "logo" .}} +

Device Verification

+

Enter the code displayed on your device

+ {{ .csrfField }} + + {{if .Error}}

{{.Error}}

{{end}} + + + +
+{{end}} From 84744d2ccb59d86ad5b0ef78b5643593f21bf35c Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 14:52:02 -0700 Subject: [PATCH 2/9] test: add functional test for Device Authorization Grant Black-box HTTP tests covering device_authorization endpoint, token polling, discovery, and error cases. Co-Authored-By: Claude Opus 4.6 --- tests/functional/tests/device-grant.test.ts | 140 ++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/functional/tests/device-grant.test.ts diff --git a/tests/functional/tests/device-grant.test.ts b/tests/functional/tests/device-grant.test.ts new file mode 100644 index 00000000..2ad21298 --- /dev/null +++ b/tests/functional/tests/device-grant.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { OAUTH_URL, getAdminToken, postJSON, postForm } from '../helpers'; + +const CLIENT_ID = 'device-functional-client'; + +describe('Device Authorization Grant (RFC 8628)', () => { + beforeAll(async () => { + const adminToken = await getAdminToken(); + const resp = await postJSON( + `${OAUTH_URL}/register`, + { + client_id: CLIENT_ID, + client_name: 'Device Functional Test Client', + redirect_uris: ['http://localhost:3000/callback'], + grant_types: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code'], + response_types: ['code'], + scopes: 'openid profile email', + client_type: 'public', + token_endpoint_auth_method: 'none', + }, + adminToken + ); + expect(resp.status).toBe(201); + }); + + it('issues device_code and user_code from device_authorization endpoint', async () => { + const resp = await postForm(`${OAUTH_URL}/device_authorization`, { + client_id: CLIENT_ID, + scope: 'openid profile', + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + + // RFC 8628 §3.2: required fields + expect(body.device_code).toBeTruthy(); + expect(body.user_code).toBeTruthy(); + expect(body.verification_uri).toBeTruthy(); + expect(body.expires_in).toBeGreaterThan(0); + + // User code should be formatted with hyphen + expect(body.user_code).toMatch(/^[A-Z]{4}-[A-Z]{4}$/); + + // verification_uri_complete should include user_code + expect(body.verification_uri_complete).toContain(body.user_code); + }); + + it('returns authorization_pending when polling before user authorizes', async () => { + const deviceResp = await postForm(`${OAUTH_URL}/device_authorization`, { + client_id: CLIENT_ID, + scope: 'openid', + }); + const { device_code } = await deviceResp.json(); + + const tokenResp = await postForm(`${OAUTH_URL}/token`, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code, + client_id: CLIENT_ID, + }); + + expect(tokenResp.status).toBe(400); + const body = await tokenResp.json(); + expect(body.error).toBe('authorization_pending'); + }); + + it('rejects device_authorization for client without device_code grant', async () => { + const resp = await postForm(`${OAUTH_URL}/device_authorization`, { + client_id: 'autentico-admin', + scope: 'openid', + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBe('unauthorized_client'); + }); + + it('rejects device_authorization with unknown client_id', async () => { + const resp = await postForm(`${OAUTH_URL}/device_authorization`, { + client_id: 'nonexistent-client', + scope: 'openid', + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBe('invalid_client'); + }); + + it('rejects device_authorization without client_id', async () => { + const resp = await postForm(`${OAUTH_URL}/device_authorization`, { + scope: 'openid', + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toContain('invalid_request'); + }); + + it('rejects token polling with invalid device_code', async () => { + const resp = await postForm(`${OAUTH_URL}/token`, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: 'totally-invalid-code', + client_id: CLIENT_ID, + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBe('invalid_grant'); + }); + + it('rejects token polling without device_code field', async () => { + const resp = await postForm(`${OAUTH_URL}/token`, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: CLIENT_ID, + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toContain('invalid_request'); + }); + + it('rejects invalid scope in device_authorization', async () => { + const resp = await postForm(`${OAUTH_URL}/device_authorization`, { + client_id: CLIENT_ID, + scope: 'openid admin_super_scope', + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBe('invalid_scope'); + }); + + it('discovery document includes device_authorization_endpoint', async () => { + const resp = await fetch(`${OAUTH_URL}/.well-known/openid-configuration`); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.device_authorization_endpoint).toBeTruthy(); + expect(body.device_authorization_endpoint).toContain('/device_authorization'); + expect(body.grant_types_supported).toContain('urn:ietf:params:oauth:grant-type:device_code'); + }); +}); From 9150426de723f095c2fee99ad9dba9a74b27745e Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 14:57:59 -0700 Subject: [PATCH 3/9] chore: add RFC annotations, admin UI grant type option, and swagger docs - Add RFC 8628 section references on all validation checks and return paths - Add "Device Code" option to admin UI grant type selectors - Regenerate swagger documentation Co-Authored-By: Claude Opus 4.6 --- .../components/clients/ClientCreateForm.tsx | 1 + .../src/components/clients/ClientEditForm.tsx | 1 + docs/docs.go | 80 +++++++++++++++++++ docs/swagger.json | 80 +++++++++++++++++++ docs/swagger.yaml | 54 +++++++++++++ pkg/devicecode/handler.go | 6 +- pkg/devicecode/service.go | 2 +- pkg/token/device_code.go | 1 + 8 files changed, 222 insertions(+), 3 deletions(-) diff --git a/admin-ui/src/components/clients/ClientCreateForm.tsx b/admin-ui/src/components/clients/ClientCreateForm.tsx index 9f198666..a8b98e75 100644 --- a/admin-ui/src/components/clients/ClientCreateForm.tsx +++ b/admin-ui/src/components/clients/ClientCreateForm.tsx @@ -34,6 +34,7 @@ const GRANT_TYPE_OPTIONS = [ { label: "Refresh Token", value: "refresh_token" }, { label: "Client Credentials", value: "client_credentials" }, { label: "Password", value: "password" }, + { label: "Device Code", value: "urn:ietf:params:oauth:grant-type:device_code" }, ]; const RESPONSE_TYPE_OPTIONS = [ diff --git a/admin-ui/src/components/clients/ClientEditForm.tsx b/admin-ui/src/components/clients/ClientEditForm.tsx index 7eada879..76bb76d1 100644 --- a/admin-ui/src/components/clients/ClientEditForm.tsx +++ b/admin-ui/src/components/clients/ClientEditForm.tsx @@ -33,6 +33,7 @@ const GRANT_TYPE_OPTIONS = [ { label: "Refresh Token", value: "refresh_token" }, { label: "Client Credentials", value: "client_credentials" }, { label: "Password", value: "password" }, + { label: "Device Code", value: "urn:ietf:params:oauth:grant-type:device_code" }, ]; const RESPONSE_TYPE_OPTIONS = [ diff --git a/docs/docs.go b/docs/docs.go index 03edbde9..958964d9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3432,6 +3432,50 @@ const docTemplate = `{ } } }, + "/oauth2/device_authorization": { + "post": { + "description": "Issues a device code and user code for device authorization flow (RFC 8628)", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth2" + ], + "summary": "Device Authorization", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Requested scope", + "name": "scope", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/devicecode.DeviceAuthorizationResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.AuthErrorResponse" + } + } + } + } + }, "/oauth2/introspect": { "post": { "description": "Validates and retrieves metadata about a token", @@ -4219,6 +4263,9 @@ const docTemplate = `{ "client_type": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4291,6 +4338,9 @@ const docTemplate = `{ "client_type": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4417,6 +4467,9 @@ const docTemplate = `{ "client_name": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4495,6 +4548,29 @@ const docTemplate = `{ } } }, + "devicecode.DeviceAuthorizationResponse": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + } + } + }, "federation.FederationProviderRequest": { "type": "object", "properties": { @@ -4960,6 +5036,10 @@ const docTemplate = `{ "type": "string" } }, + "device_authorization_endpoint": { + "description": "RFC 8628 §4: device_authorization_endpoint.", + "type": "string" + }, "end_session_endpoint": { "description": "OIDC RP-Initiated Logout 1.0 §2.1: end_session_endpoint.", "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 48a73db4..97c72bb1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3426,6 +3426,50 @@ } } }, + "/oauth2/device_authorization": { + "post": { + "description": "Issues a device code and user code for device authorization flow (RFC 8628)", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth2" + ], + "summary": "Device Authorization", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Requested scope", + "name": "scope", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/devicecode.DeviceAuthorizationResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.AuthErrorResponse" + } + } + } + } + }, "/oauth2/introspect": { "post": { "description": "Validates and retrieves metadata about a token", @@ -4213,6 +4257,9 @@ "client_type": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4285,6 +4332,9 @@ "client_type": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4411,6 +4461,9 @@ "client_name": { "type": "string" }, + "consent_required": { + "type": "boolean" + }, "grant_types": { "type": "array", "items": { @@ -4489,6 +4542,29 @@ } } }, + "devicecode.DeviceAuthorizationResponse": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + } + } + }, "federation.FederationProviderRequest": { "type": "object", "properties": { @@ -4954,6 +5030,10 @@ "type": "string" } }, + "device_authorization_endpoint": { + "description": "RFC 8628 §4: device_authorization_endpoint.", + "type": "string" + }, "end_session_endpoint": { "description": "OIDC RP-Initiated Logout 1.0 §2.1: end_session_endpoint.", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3af4da70..27691f58 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -182,6 +182,8 @@ definitions: type: string client_type: type: string + consent_required: + type: boolean grant_types: items: type: string @@ -230,6 +232,8 @@ definitions: type: string client_type: type: string + consent_required: + type: boolean grant_types: items: type: string @@ -318,6 +322,8 @@ definitions: type: string client_name: type: string + consent_required: + type: boolean grant_types: items: type: string @@ -369,6 +375,21 @@ definitions: username: type: string type: object + devicecode.DeviceAuthorizationResponse: + properties: + device_code: + type: string + expires_in: + type: integer + interval: + type: integer + user_code: + type: string + verification_uri: + type: string + verification_uri_complete: + type: string + type: object federation.FederationProviderRequest: properties: client_id: @@ -679,6 +700,9 @@ definitions: items: type: string type: array + device_authorization_endpoint: + description: 'RFC 8628 §4: device_authorization_endpoint.' + type: string end_session_endpoint: description: 'OIDC RP-Initiated Logout 1.0 §2.1: end_session_endpoint.' type: string @@ -3153,6 +3177,36 @@ paths: summary: Authorize a client tags: - oauth2 + /oauth2/device_authorization: + post: + consumes: + - application/x-www-form-urlencoded + description: Issues a device code and user code for device authorization flow + (RFC 8628) + parameters: + - description: Client ID + in: formData + name: client_id + required: true + type: string + - description: Requested scope + in: formData + name: scope + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/devicecode.DeviceAuthorizationResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.AuthErrorResponse' + summary: Device Authorization + tags: + - oauth2 /oauth2/introspect: post: consumes: diff --git a/pkg/devicecode/handler.go b/pkg/devicecode/handler.go index 05eee928..3c7c44e8 100644 --- a/pkg/devicecode/handler.go +++ b/pkg/devicecode/handler.go @@ -52,6 +52,7 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { return } + // RFC 8628 §3.1: client_id is REQUIRED in the device authorization request clientID := r.FormValue("client_id") scope := r.FormValue("scope") @@ -74,7 +75,7 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { return } - // Validate scope against client's allowed scopes + // RFC 8628 §3.1: scope is OPTIONAL; validate against client's allowed scopes if scope != "" && !client.ValidateScopes(registeredClient, scope) { utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_scope", "One or more requested scopes are not allowed for this client") return @@ -117,7 +118,7 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { bs := config.GetBootstrap() verificationURI := fmt.Sprintf("%s%s/device", bs.AppURL, bs.AppOAuthPath) - // RFC 8628 §3.2: Device Authorization Response + // RFC 8628 §3.2: response MUST include device_code, user_code, verification_uri, expires_in resp := DeviceAuthorizationResponse{ DeviceCode: deviceCode, UserCode: FormatUserCode(userCode), @@ -127,6 +128,7 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { Interval: cfg.DeviceCodePollingInterval, } + // RFC 8628 §3.2: response MUST NOT be cached w.Header().Set("Cache-Control", "no-store") utils.WriteApiResponse(w, resp, http.StatusOK) } diff --git a/pkg/devicecode/service.go b/pkg/devicecode/service.go index 0977cfcb..b8235ae8 100644 --- a/pkg/devicecode/service.go +++ b/pkg/devicecode/service.go @@ -7,7 +7,7 @@ import ( "strings" ) -// RFC 8628 §6.1: device_code must have at least 160 bits of entropy. +// RFC 8628 §6.1: device_code MUST have sufficient entropy to prevent brute-force guessing (160+ bits). func GenerateDeviceCode() (string, error) { b := make([]byte, 20) if _, err := rand.Read(b); err != nil { diff --git a/pkg/token/device_code.go b/pkg/token/device_code.go index 27da4f5d..fedb7c59 100644 --- a/pkg/token/device_code.go +++ b/pkg/token/device_code.go @@ -13,6 +13,7 @@ import ( // handleDeviceCodeGrant implements the token polling side of RFC 8628 §3.4-3.5. func handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request, request TokenRequest) (*user.User, string, error) { + // RFC 8628 §3.4: device_code is REQUIRED in the token request if request.DeviceCode == "" { utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "device_code is required") return nil, "", errGrantHandled From 93e35f761e3c35c856ede92349c00007bae08e6a Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:27:52 -0700 Subject: [PATCH 4/9] feat: move device verification to account UI with dedicated API endpoints Replace server-rendered device verification templates with a React page in the account UI. This leverages the existing OIDC authentication flow instead of reimplementing login/MFA/passkey handling in the device flow. - Add /account/api/device/{verify,authorize,deny} API endpoints - Add Device.tsx page with code input, confirmation, and result states - Add /device redirect handler for convenience URLs - Remove server-rendered device_verify/confirm/success/denied templates - Update verification_uri to use path format (/account/device/{code}) - Add device flow test page to debug-ui Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- account-ui/src/App.tsx | 13 ++ account-ui/src/pages/Device.tsx | 211 ++++++++++++++++++++++++ debug-ui/src/App.tsx | 2 + debug-ui/src/pages/DevicePage.tsx | 261 ++++++++++++++++++++++++++++++ debug-ui/src/pages/LoginPage.tsx | 23 ++- pkg/account/device.go | 153 ++++++++++++++++++ pkg/cli/start.go | 7 +- pkg/devicecode/handler.go | 187 ++------------------- pkg/devicecode/handler_test.go | 3 +- view/device_confirm.html | 16 -- view/device_denied.html | 9 -- view/device_success.html | 9 -- view/device_verify.html | 15 -- 14 files changed, 677 insertions(+), 234 deletions(-) create mode 100644 account-ui/src/pages/Device.tsx create mode 100644 debug-ui/src/pages/DevicePage.tsx create mode 100644 pkg/account/device.go delete mode 100644 view/device_confirm.html delete mode 100644 view/device_denied.html delete mode 100644 view/device_success.html delete mode 100644 view/device_verify.html diff --git a/CLAUDE.md b/CLAUDE.md index a7530529..d92056bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ sudo ln -sf ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome /opt/google/ # Register the debug client (with consent_required for consent screen testing) curl -s -X POST http://localhost:9999/admin/api/clients \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ - -d '{"client_id":"autentico-debug","client_name":"Debug UI","redirect_uris":["http://localhost:5174/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"scopes":"openid profile email offline_access","client_type":"public","token_endpoint_auth_method":"none","consent_required":true}' + -d '{"client_id":"autentico-debug","client_name":"Debug UI","redirect_uris":["http://localhost:5174/callback"],"grant_types":["authorization_code","refresh_token","urn:ietf:params:oauth:grant-type:device_code"],"response_types":["code"],"scopes":"openid profile email offline_access","client_type":"public","token_endpoint_auth_method":"none","consent_required":true}' # Add CORS origin for the debug UI curl -s -X PUT http://localhost:9999/admin/api/settings \ diff --git a/account-ui/src/App.tsx b/account-ui/src/App.tsx index 6c5eb759..6174e576 100644 --- a/account-ui/src/App.tsx +++ b/account-ui/src/App.tsx @@ -7,6 +7,9 @@ import { SettingsProvider, useSettings } from './context/SettingsContext'; import AuthBridge from './components/AuthBridge'; import Layout from './components/Layout'; import Callback from './pages/Callback'; +import Device from './pages/Device'; +import { RequireAuth } from 'oidc-js-react'; +import Spinner from './components/Spinner'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1 } }, @@ -51,6 +54,16 @@ function App() { } /> + }> + + + } /> + }> + + + } /> } /> diff --git a/account-ui/src/pages/Device.tsx b/account-ui/src/pages/Device.tsx new file mode 100644 index 00000000..b6bc416e --- /dev/null +++ b/account-ui/src/pages/Device.tsx @@ -0,0 +1,211 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { IconDevices, IconCheck, IconX } from '@tabler/icons-react'; +import { useAuth } from 'oidc-js-react'; +import api from '../api'; +import Alert from '../components/Alert'; +import Spinner from '../components/Spinner'; + +type Status = 'input' | 'loading' | 'confirm' | 'authorized' | 'denied' | 'error'; + +interface DeviceInfo { + user_code: string; + client_name: string; + scope: string; +} + +const scopeDescriptions: Record = { + openid: 'Verify your identity', + profile: 'View your profile information', + email: 'View your email address', + address: 'View your address', + phone: 'View your phone number', + offline_access: 'Stay signed in between sessions', +}; + +export default function DevicePage() { + const { code } = useParams<{ code: string }>(); + const { user } = useAuth(); + const [status, setStatus] = useState('input'); + const [userCode, setUserCode] = useState(code ?? ''); + const [deviceInfo, setDeviceInfo] = useState(null); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (code) { + verifyCode(code); + } + }, [code]); + + const verifyCode = async (codeValue: string) => { + setStatus('loading'); + setError(''); + try { + const { data } = await api.post('/device/verify', { user_code: codeValue }); + setDeviceInfo(data); + setUserCode(data.user_code); + setStatus('confirm'); + } catch (err: any) { + const msg = err?.response?.data?.error_description || err?.response?.data?.message || 'Invalid or expired code'; + setError(msg); + setStatus('error'); + } + }; + + const handleSubmitCode = (e: React.FormEvent) => { + e.preventDefault(); + if (!userCode.trim()) return; + verifyCode(userCode.trim()); + }; + + const handleAuthorize = async () => { + setSubmitting(true); + try { + await api.post('/device/authorize', { user_code: userCode }); + setStatus('authorized'); + } catch (err: any) { + const msg = err?.response?.data?.error_description || 'Failed to authorize device'; + setError(msg); + setStatus('error'); + } finally { + setSubmitting(false); + } + }; + + const handleDeny = async () => { + setSubmitting(true); + try { + await api.post('/device/deny', { user_code: userCode }); + setStatus('denied'); + } catch (err: any) { + const msg = err?.response?.data?.error_description || 'Failed to deny device'; + setError(msg); + setStatus('error'); + } finally { + setSubmitting(false); + } + }; + + const scopes = deviceInfo?.scope?.split(' ').filter(Boolean) ?? []; + + return ( +
+
+ {status === 'input' && ( + <> +
+ +

Link a Device

+

+ Enter the code displayed on your device. +

+
+
+ setUserCode(e.target.value.toUpperCase())} + placeholder="XXXX-XXXX" + maxLength={9} + autoFocus + /> + +
+ + )} + + {status === 'loading' && ( +
+ +
+ )} + + {status === 'confirm' && deviceInfo && ( + <> +
+ +

Authorize Device

+
+
+

+ {deviceInfo.client_name} is requesting access to your account + {user?.claims?.preferred_username ? ( + <> as {String(user.claims.preferred_username)} + ) : null}. +

+

{deviceInfo.user_code}

+ {scopes.length > 0 && ( +
    + {scopes.map((scope) => ( +
  • + {scopeDescriptions[scope] ?? scope} +
  • + ))} +
+ )} +
+
+ + +
+ + )} + + {status === 'authorized' && ( +
+
+ +
+

Device Authorized

+

+ You can return to your device. This page can be closed. +

+
+ )} + + {status === 'denied' && ( +
+
+ +
+

Access Denied

+

+ The device will not be granted access. +

+
+ )} + + {status === 'error' && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/debug-ui/src/App.tsx b/debug-ui/src/App.tsx index 7f8241c6..24928e04 100644 --- a/debug-ui/src/App.tsx +++ b/debug-ui/src/App.tsx @@ -6,6 +6,7 @@ import ProtectedRoute from "./components/ProtectedRoute"; import LoginPage from "./pages/LoginPage"; import CallbackPage from "./pages/CallbackPage"; import DashboardPage from "./pages/DashboardPage"; +import DevicePage from "./pages/DevicePage"; const queryClient = new QueryClient(); @@ -22,6 +23,7 @@ export default function App() { } /> } /> + } /> }> } /> diff --git a/debug-ui/src/pages/DevicePage.tsx b/debug-ui/src/pages/DevicePage.tsx new file mode 100644 index 00000000..d93ec6ba --- /dev/null +++ b/debug-ui/src/pages/DevicePage.tsx @@ -0,0 +1,261 @@ +import { useState, useEffect, useRef } from "react"; +import { Button, Card, Typography, Space, Alert, Spin, Descriptions } from "antd"; +import { MobileOutlined, CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; + +const { Title, Text, Paragraph } = Typography; + +const AUTHORITY = "/oauth2"; +const CLIENT_ID = "autentico-debug"; + +interface DeviceAuthResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +interface TokenResponse { + access_token: string; + refresh_token: string; + id_token: string; + token_type: string; + expires_in: number; + scope: string; +} + +type Status = "idle" | "polling" | "authorized" | "denied" | "expired" | "error"; + +export default function DevicePage() { + const [status, setStatus] = useState("idle"); + const [deviceAuth, setDeviceAuth] = useState(null); + const [tokens, setTokens] = useState(null); + const [error, setError] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(0); + const pollRef = useRef | null>(null); + const countdownRef = useRef | null>(null); + + const startDeviceFlow = async () => { + setStatus("idle"); + setError(null); + setTokens(null); + setDeviceAuth(null); + + try { + const resp = await fetch(`${AUTHORITY}/device_authorization`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: "openid profile email", + }), + }); + + if (!resp.ok) { + const body = await resp.json(); + setError(body.error_description || body.error || "Failed to start device flow"); + setStatus("error"); + return; + } + + const data: DeviceAuthResponse = await resp.json(); + setDeviceAuth(data); + setSecondsLeft(data.expires_in); + setStatus("polling"); + startPolling(data.device_code, data.interval); + startCountdown(data.expires_in); + } catch (err) { + setError(String(err)); + setStatus("error"); + } + }; + + const startPolling = (deviceCode: string, interval: number) => { + if (pollRef.current) clearInterval(pollRef.current); + + pollRef.current = setInterval(async () => { + try { + const resp = await fetch(`${AUTHORITY}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: CLIENT_ID, + }), + }); + + if (resp.ok) { + const data: TokenResponse = await resp.json(); + setTokens(data); + setStatus("authorized"); + stopPolling(); + return; + } + + const body = await resp.json(); + switch (body.error) { + case "authorization_pending": + break; + case "slow_down": + // Back off by restarting with longer interval + stopPolling(); + pollRef.current = setInterval(() => { + startPolling(deviceCode, interval + 5); + }, (interval + 5) * 1000); + break; + case "access_denied": + setStatus("denied"); + stopPolling(); + break; + case "expired_token": + setStatus("expired"); + stopPolling(); + break; + default: + setError(body.error_description || body.error); + setStatus("error"); + stopPolling(); + } + } catch (err) { + setError(String(err)); + setStatus("error"); + stopPolling(); + } + }, interval * 1000); + }; + + const startCountdown = (seconds: number) => { + if (countdownRef.current) clearInterval(countdownRef.current); + let remaining = seconds; + countdownRef.current = setInterval(() => { + remaining--; + setSecondsLeft(remaining); + if (remaining <= 0) { + if (countdownRef.current) clearInterval(countdownRef.current); + } + }, 1000); + }; + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }; + + useEffect(() => { + return () => stopPolling(); + }, []); + + return ( +
+ + + <MobileOutlined /> Device Flow + Simulates a device (CLI/TV) requesting authorization + + {status === "idle" && ( + + )} + + {status === "polling" && deviceAuth && ( + <> + + + {deviceAuth.user_code} + + Visit: + {deviceAuth.verification_uri} + +
+ } + type="info" + /> + + + Waiting for authorization... ({secondsLeft}s remaining) + + + + )} + + {status === "authorized" && tokens && ( + <> + } + showIcon + /> + + + + {tokens.access_token.substring(0, 50)}... + + + + + {tokens.refresh_token.substring(0, 50)}... + + + {tokens.scope} + {tokens.expires_in}s + + + + )} + + {status === "denied" && ( + <> + } + showIcon + /> + + + )} + + {status === "expired" && ( + <> + + + + )} + + {status === "error" && ( + <> + + + + )} + + + + ); +} diff --git a/debug-ui/src/pages/LoginPage.tsx b/debug-ui/src/pages/LoginPage.tsx index 15bef7e9..04d2f303 100644 --- a/debug-ui/src/pages/LoginPage.tsx +++ b/debug-ui/src/pages/LoginPage.tsx @@ -1,12 +1,14 @@ import { useEffect } from "react"; -import { Button, Card, Typography, Space } from "antd"; -import { LoginOutlined } from "@ant-design/icons"; +import { Button, Card, Typography, Space, Divider } from "antd"; +import { LoginOutlined, MobileOutlined } from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; const { Title, Text } = Typography; export default function LoginPage() { const { startLogin, isAuthenticated } = useAuth(); + const navigate = useNavigate(); useEffect(() => { if (isAuthenticated) { @@ -26,15 +28,24 @@ export default function LoginPage() { Debug UI Welcome to the Token Debugger. Please log in to view and test your tokens. - + or + diff --git a/pkg/account/device.go b/pkg/account/device.go new file mode 100644 index 00000000..d69ae046 --- /dev/null +++ b/pkg/account/device.go @@ -0,0 +1,153 @@ +package account + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/eugenioenko/autentico/pkg/client" + "github.com/eugenioenko/autentico/pkg/devicecode" + "github.com/eugenioenko/autentico/pkg/middleware" + "github.com/eugenioenko/autentico/pkg/utils" +) + +type DeviceVerifyRequest struct { + UserCode string `json:"user_code"` +} + +type DeviceVerifyResponse struct { + UserCode string `json:"user_code"` + ClientName string `json:"client_name"` + Scope string `json:"scope"` +} + +// HandleDeviceVerify looks up a device code by user_code and returns the client info. +func HandleDeviceVerify(w http.ResponseWriter, r *http.Request) { + usr := middleware.UserFromContext(r.Context()) + if usr == nil { + utils.WriteErrorResponse(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + var req DeviceVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + userCode := devicecode.NormalizeUserCode(req.UserCode) + if userCode == "" { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "user_code is required") + return + } + + dc, err := devicecode.DeviceCodeByUserCode(userCode) + if err != nil || dc == nil { + utils.WriteErrorResponse(w, http.StatusNotFound, "not_found", "Invalid or unknown code") + return + } + + if time.Now().After(dc.ExpiresAt) { + utils.WriteErrorResponse(w, http.StatusGone, "expired", "This code has expired") + return + } + + if dc.Status != "pending" { + utils.WriteErrorResponse(w, http.StatusConflict, "already_used", "This code has already been used") + return + } + + clientName := dc.ClientID + if registeredClient, err := client.ClientByClientID(dc.ClientID); err == nil && registeredClient != nil { + clientName = registeredClient.ClientName + } + + utils.WriteApiResponse(w, DeviceVerifyResponse{ + UserCode: devicecode.FormatUserCode(userCode), + ClientName: clientName, + Scope: dc.Scope, + }, http.StatusOK) +} + +// HandleDeviceAuthorize authorizes a pending device code for the current user. +func HandleDeviceAuthorize(w http.ResponseWriter, r *http.Request) { + usr := middleware.UserFromContext(r.Context()) + if usr == nil { + utils.WriteErrorResponse(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + var req DeviceVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + userCode := devicecode.NormalizeUserCode(req.UserCode) + if userCode == "" { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "user_code is required") + return + } + + dc, err := devicecode.DeviceCodeByUserCode(userCode) + if err != nil || dc == nil { + utils.WriteErrorResponse(w, http.StatusNotFound, "not_found", "Invalid or unknown code") + return + } + + if time.Now().After(dc.ExpiresAt) { + utils.WriteErrorResponse(w, http.StatusGone, "expired", "This code has expired") + return + } + + if dc.Status != "pending" { + utils.WriteErrorResponse(w, http.StatusConflict, "already_used", "This code has already been used") + return + } + + if err := devicecode.AuthorizeDeviceCode(userCode, usr.ID); err != nil { + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Failed to authorize device") + return + } + + utils.WriteApiResponse(w, map[string]string{"status": "authorized"}, http.StatusOK) +} + +// HandleDeviceDeny denies a pending device code. +func HandleDeviceDeny(w http.ResponseWriter, r *http.Request) { + usr := middleware.UserFromContext(r.Context()) + if usr == nil { + utils.WriteErrorResponse(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + + var req DeviceVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + userCode := devicecode.NormalizeUserCode(req.UserCode) + if userCode == "" { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "user_code is required") + return + } + + dc, err := devicecode.DeviceCodeByUserCode(userCode) + if err != nil || dc == nil { + utils.WriteErrorResponse(w, http.StatusNotFound, "not_found", "Invalid or unknown code") + return + } + + if dc.Status != "pending" { + utils.WriteErrorResponse(w, http.StatusConflict, "already_used", "This code has already been used") + return + } + + if err := devicecode.DenyDeviceCode(userCode); err != nil { + utils.WriteErrorResponse(w, http.StatusInternalServerError, "server_error", "Failed to deny device") + return + } + + utils.WriteApiResponse(w, map[string]string{"status": "denied"}, http.StatusOK) +} diff --git a/pkg/cli/start.go b/pkg/cli/start.go index 8fc14abc..10f991d8 100644 --- a/pkg/cli/start.go +++ b/pkg/cli/start.go @@ -156,7 +156,9 @@ func RunStart(c *cli.Context) error { mux.Handle("POST "+oauth+"/token", rateLimitedFunc(token.HandleToken)) mux.Handle("POST "+oauth+"/protocol/openid-connect/token", rateLimitedFunc(token.HandleToken)) mux.Handle("POST "+oauth+"/device_authorization", rateLimitedFunc(devicecode.HandleDeviceAuthorization)) - mux.Handle(oauth+"/device", rateLimited(csrfProtected(devicecode.HandleDeviceVerification))) + mux.HandleFunc("GET /device/{code}", devicecode.HandleDeviceRedirect) + mux.HandleFunc("GET /device", devicecode.HandleDeviceRedirect) + mux.HandleFunc("GET /device/", devicecode.HandleDeviceRedirect) mux.Handle("POST "+oauth+"/revoke", rateLimitedFunc(revoke.HandleRevoke)) mux.Handle("POST "+oauth+"/introspect", rateLimitedFunc(introspect.HandleIntrospect)) mux.Handle(oauth+"/userinfo", rateLimitedFunc(userinfo.HandleUserInfo)) @@ -241,6 +243,9 @@ func RunStart(c *cli.Context) error { mux.Handle("DELETE /account/api/trusted-devices/{id}", accountAPI(account.HandleRevokeTrustedDevice)) mux.Handle("GET /account/api/connected-providers", accountAPI(account.HandleListConnectedProviders)) mux.Handle("DELETE /account/api/connected-providers/{id}", accountAPI(account.HandleDisconnectProvider)) + mux.Handle("POST /account/api/device/verify", accountAPI(account.HandleDeviceVerify)) + mux.Handle("POST /account/api/device/authorize", accountAPI(account.HandleDeviceAuthorize)) + mux.Handle("POST /account/api/device/deny", accountAPI(account.HandleDeviceDeny)) mux.HandleFunc("GET /account/api/settings", account.HandleGetSettings) mux.Handle("GET /account/api/deletion-request", accountAPI(deletion.HandleGetDeletionRequest)) mux.Handle("POST /account/api/deletion-request", accountAPI(deletion.HandleRequestDeletion)) diff --git a/pkg/devicecode/handler.go b/pkg/devicecode/handler.go index 3c7c44e8..f80d9f1e 100644 --- a/pkg/devicecode/handler.go +++ b/pkg/devicecode/handler.go @@ -8,24 +8,20 @@ import ( "github.com/eugenioenko/autentico/pkg/client" "github.com/eugenioenko/autentico/pkg/config" - "github.com/eugenioenko/autentico/pkg/idpsession" "github.com/eugenioenko/autentico/pkg/reqid" "github.com/eugenioenko/autentico/pkg/utils" - "github.com/eugenioenko/autentico/view" - "github.com/gorilla/csrf" ) -// getAuthenticatedUserID extracts the currently logged-in user from the IdP session cookie. -func getAuthenticatedUserID(r *http.Request) string { - sessionID := idpsession.ReadCookie(r) - if sessionID == "" { - return "" +// HandleDeviceRedirect redirects /device and /device/:code to the account UI device page. +func HandleDeviceRedirect(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + target := "/account/device" + if code != "" { + target = "/account/device/" + code + } else if userCode := r.URL.Query().Get("user_code"); userCode != "" { + target = "/account/device/" + userCode } - sess, err := idpsession.IdpSessionByID(sessionID) - if err != nil || sess == nil { - return "" - } - return sess.UserID + http.Redirect(w, r, target, http.StatusFound) } const DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" @@ -116,14 +112,14 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { } bs := config.GetBootstrap() - verificationURI := fmt.Sprintf("%s%s/device", bs.AppURL, bs.AppOAuthPath) + verificationURI := fmt.Sprintf("%s/account/device", bs.AppURL) // RFC 8628 §3.2: response MUST include device_code, user_code, verification_uri, expires_in resp := DeviceAuthorizationResponse{ DeviceCode: deviceCode, UserCode: FormatUserCode(userCode), VerificationURI: verificationURI, - VerificationURIComplete: fmt.Sprintf("%s?user_code=%s", verificationURI, FormatUserCode(userCode)), + VerificationURIComplete: fmt.Sprintf("%s/%s", verificationURI, FormatUserCode(userCode)), ExpiresIn: int(cfg.DeviceCodeExpiration.Seconds()), Interval: cfg.DeviceCodePollingInterval, } @@ -133,164 +129,3 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { utils.WriteApiResponse(w, resp, http.StatusOK) } -// HandleDeviceVerification renders the device verification page and handles form submission. -func HandleDeviceVerification(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - handleDeviceVerifyGet(w, r) - case http.MethodPost: - handleDeviceVerifyPost(w, r) - default: - view.RenderError(w, r, http.StatusMethodNotAllowed, "Method not allowed.") - } -} - -func handleDeviceVerifyGet(w http.ResponseWriter, r *http.Request) { - userCode := r.URL.Query().Get("user_code") - renderDeviceVerifyPage(w, r, userCode, "") -} - -func handleDeviceVerifyPost(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - renderDeviceVerifyPage(w, r, "", "Invalid form data") - return - } - - action := r.FormValue("action") - userCode := NormalizeUserCode(r.FormValue("user_code")) - - // Check if user is authenticated via IdP session - userID := getAuthenticatedUserID(r) - if userID == "" { - // Not authenticated — redirect to login with return URL - bs := config.GetBootstrap() - returnURL := fmt.Sprintf("%s%s/device?user_code=%s", bs.AppURL, bs.AppOAuthPath, FormatUserCode(userCode)) - loginURL := fmt.Sprintf("%s%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&prompt=login&device_return=%s", - bs.AppURL, bs.AppOAuthPath, config.AdminClientID, bs.AppURL, returnURL) - http.Redirect(w, r, loginURL, http.StatusFound) - return - } - - // Lookup the device code - dc, err := DeviceCodeByUserCode(userCode) - if err != nil || dc == nil { - renderDeviceVerifyPage(w, r, "", "Invalid or expired code. Please check and try again.") - return - } - - if time.Now().After(dc.ExpiresAt) { - renderDeviceVerifyPage(w, r, "", "This code has expired. Please request a new one from your device.") - return - } - - if dc.Status != "pending" { - renderDeviceVerifyPage(w, r, "", "This code has already been used.") - return - } - - // If no action yet, show the confirmation page - if action == "" || action == "verify" { - registeredClient, _ := client.ClientByClientID(dc.ClientID) - clientName := dc.ClientID - if registeredClient != nil { - clientName = registeredClient.ClientName - } - renderDeviceConfirmPage(w, r, userCode, clientName, dc.Scope) - return - } - - // Handle authorize/deny - switch action { - case "allow": - if err := AuthorizeDeviceCode(userCode, userID); err != nil { - slog.Error("device_verify: failed to authorize", "error", err) - renderDeviceVerifyPage(w, r, "", "Something went wrong. Please try again.") - return - } - renderDeviceSuccessPage(w, r) - case "deny": - if err := DenyDeviceCode(userCode); err != nil { - slog.Error("device_verify: failed to deny", "error", err) - renderDeviceVerifyPage(w, r, "", "Something went wrong. Please try again.") - return - } - renderDeviceDeniedPage(w, r) - default: - renderDeviceVerifyPage(w, r, userCode, "Invalid action.") - } -} - -func renderDeviceVerifyPage(w http.ResponseWriter, r *http.Request, userCode string, errMsg string) { - cfg := config.Get() - tmpl, err := view.ParseTemplate("device_verify") - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - data := map[string]any{ - "UserCode": FormatUserCode(NormalizeUserCode(userCode)), - "Error": errMsg, - "ThemeTitle": cfg.Theme.Title, - "ThemeLogoUrl": cfg.Theme.LogoUrl, - csrf.TemplateTag: csrf.TemplateField(r), - } - view.InjectNonce(r, data) - if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { - http.Error(w, "Template Execution Error", http.StatusInternalServerError) - } -} - -func renderDeviceConfirmPage(w http.ResponseWriter, r *http.Request, userCode string, clientName string, scope string) { - cfg := config.Get() - tmpl, err := view.ParseTemplate("device_confirm") - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - data := map[string]any{ - "UserCode": FormatUserCode(userCode), - "ClientName": clientName, - "Scope": scope, - "ThemeTitle": cfg.Theme.Title, - "ThemeLogoUrl": cfg.Theme.LogoUrl, - csrf.TemplateTag: csrf.TemplateField(r), - } - view.InjectNonce(r, data) - if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { - http.Error(w, "Template Execution Error", http.StatusInternalServerError) - } -} - -func renderDeviceSuccessPage(w http.ResponseWriter, r *http.Request) { - cfg := config.Get() - tmpl, err := view.ParseTemplate("device_success") - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - data := map[string]any{ - "ThemeTitle": cfg.Theme.Title, - "ThemeLogoUrl": cfg.Theme.LogoUrl, - } - view.InjectNonce(r, data) - if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { - http.Error(w, "Template Execution Error", http.StatusInternalServerError) - } -} - -func renderDeviceDeniedPage(w http.ResponseWriter, r *http.Request) { - cfg := config.Get() - tmpl, err := view.ParseTemplate("device_denied") - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - data := map[string]any{ - "ThemeTitle": cfg.Theme.Title, - "ThemeLogoUrl": cfg.Theme.LogoUrl, - } - view.InjectNonce(r, data) - if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { - http.Error(w, "Template Execution Error", http.StatusInternalServerError) - } -} diff --git a/pkg/devicecode/handler_test.go b/pkg/devicecode/handler_test.go index 5a19c47a..69480951 100644 --- a/pkg/devicecode/handler_test.go +++ b/pkg/devicecode/handler_test.go @@ -60,7 +60,8 @@ func TestHandleDeviceAuthorization_Success(t *testing.T) { assert.Equal(t, 600, resp.ExpiresIn) assert.Equal(t, 5, resp.Interval) assert.Contains(t, resp.VerificationURI, "/device") - assert.Contains(t, resp.VerificationURIComplete, "user_code=") + assert.Contains(t, resp.VerificationURIComplete, "/account/device/") + assert.Contains(t, resp.VerificationURIComplete, resp.UserCode) } func TestHandleDeviceAuthorization_MissingClientID(t *testing.T) { diff --git a/view/device_confirm.html b/view/device_confirm.html deleted file mode 100644 index 397f84de..00000000 --- a/view/device_confirm.html +++ /dev/null @@ -1,16 +0,0 @@ -{{define "title"}}{{.ThemeTitle}} - Authorize Device{{end}} - -{{define "content"}} -
- {{template "logo" .}} -

Authorize Device

-

{{.ClientName}} is requesting access to your account

- {{ .csrfField }} - - {{if .Scope}} -

Requested scopes: {{.Scope}}

- {{end}} - - -
-{{end}} diff --git a/view/device_denied.html b/view/device_denied.html deleted file mode 100644 index b051c2b2..00000000 --- a/view/device_denied.html +++ /dev/null @@ -1,9 +0,0 @@ -{{define "title"}}{{.ThemeTitle}} - Access Denied{{end}} - -{{define "content"}} -
- {{template "logo" .}} -

Access Denied

-

You have denied access to the device. You can close this window.

-
-{{end}} diff --git a/view/device_success.html b/view/device_success.html deleted file mode 100644 index 735f07ff..00000000 --- a/view/device_success.html +++ /dev/null @@ -1,9 +0,0 @@ -{{define "title"}}{{.ThemeTitle}} - Device Authorized{{end}} - -{{define "content"}} -
- {{template "logo" .}} -

Device Authorized

-

You have successfully authorized the device. You can close this window.

-
-{{end}} diff --git a/view/device_verify.html b/view/device_verify.html deleted file mode 100644 index 94ef5cca..00000000 --- a/view/device_verify.html +++ /dev/null @@ -1,15 +0,0 @@ -{{define "title"}}{{.ThemeTitle}} - Device Verification{{end}} - -{{define "content"}} -
- {{template "logo" .}} -

Device Verification

-

Enter the code displayed on your device

- {{ .csrfField }} - - {{if .Error}}

{{.Error}}

{{end}} - - - -
-{{end}} From f7bde3edcda1163976d07632dca7693d561bc548 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:30:19 -0700 Subject: [PATCH 5/9] cleanup: remove unused /device redirect routes and handler The verification_uri already points directly to /account/device, so the /device redirect routes were dead code. Co-Authored-By: Claude Opus 4.6 --- pkg/cli/start.go | 3 --- pkg/devicecode/handler.go | 12 ------------ 2 files changed, 15 deletions(-) diff --git a/pkg/cli/start.go b/pkg/cli/start.go index 10f991d8..7efaa64f 100644 --- a/pkg/cli/start.go +++ b/pkg/cli/start.go @@ -156,9 +156,6 @@ func RunStart(c *cli.Context) error { mux.Handle("POST "+oauth+"/token", rateLimitedFunc(token.HandleToken)) mux.Handle("POST "+oauth+"/protocol/openid-connect/token", rateLimitedFunc(token.HandleToken)) mux.Handle("POST "+oauth+"/device_authorization", rateLimitedFunc(devicecode.HandleDeviceAuthorization)) - mux.HandleFunc("GET /device/{code}", devicecode.HandleDeviceRedirect) - mux.HandleFunc("GET /device", devicecode.HandleDeviceRedirect) - mux.HandleFunc("GET /device/", devicecode.HandleDeviceRedirect) mux.Handle("POST "+oauth+"/revoke", rateLimitedFunc(revoke.HandleRevoke)) mux.Handle("POST "+oauth+"/introspect", rateLimitedFunc(introspect.HandleIntrospect)) mux.Handle(oauth+"/userinfo", rateLimitedFunc(userinfo.HandleUserInfo)) diff --git a/pkg/devicecode/handler.go b/pkg/devicecode/handler.go index f80d9f1e..e4267a7b 100644 --- a/pkg/devicecode/handler.go +++ b/pkg/devicecode/handler.go @@ -12,18 +12,6 @@ import ( "github.com/eugenioenko/autentico/pkg/utils" ) -// HandleDeviceRedirect redirects /device and /device/:code to the account UI device page. -func HandleDeviceRedirect(w http.ResponseWriter, r *http.Request) { - code := r.PathValue("code") - target := "/account/device" - if code != "" { - target = "/account/device/" + code - } else if userCode := r.URL.Query().Get("user_code"); userCode != "" { - target = "/account/device/" + userCode - } - http.Redirect(w, r, target, http.StatusFound) -} - const DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" // HandleDeviceAuthorization handles the device authorization request. From 586da35c2dc65665a06ab0794d04f584da558f46 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:33:13 -0700 Subject: [PATCH 6/9] security: add rate limiting to device verification API endpoints Co-Authored-By: Claude Opus 4.6 --- pkg/cli/start.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cli/start.go b/pkg/cli/start.go index 7efaa64f..db79362e 100644 --- a/pkg/cli/start.go +++ b/pkg/cli/start.go @@ -240,9 +240,9 @@ func RunStart(c *cli.Context) error { mux.Handle("DELETE /account/api/trusted-devices/{id}", accountAPI(account.HandleRevokeTrustedDevice)) mux.Handle("GET /account/api/connected-providers", accountAPI(account.HandleListConnectedProviders)) mux.Handle("DELETE /account/api/connected-providers/{id}", accountAPI(account.HandleDisconnectProvider)) - mux.Handle("POST /account/api/device/verify", accountAPI(account.HandleDeviceVerify)) - mux.Handle("POST /account/api/device/authorize", accountAPI(account.HandleDeviceAuthorize)) - mux.Handle("POST /account/api/device/deny", accountAPI(account.HandleDeviceDeny)) + mux.Handle("POST /account/api/device/verify", rateLimited(accountAPI(account.HandleDeviceVerify))) + mux.Handle("POST /account/api/device/authorize", rateLimited(accountAPI(account.HandleDeviceAuthorize))) + mux.Handle("POST /account/api/device/deny", rateLimited(accountAPI(account.HandleDeviceDeny))) mux.HandleFunc("GET /account/api/settings", account.HandleGetSettings) mux.Handle("GET /account/api/deletion-request", accountAPI(deletion.HandleGetDeletionRequest)) mux.Handle("POST /account/api/deletion-request", accountAPI(deletion.HandleRequestDeletion)) From e0a423a7ea6f0bb3c578765dc71625c8b4e6de57 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:42:40 -0700 Subject: [PATCH 7/9] security: enforce single-use device codes and client_id binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark device codes as consumed after successful token exchange to prevent replay attacks (RFC 8628 §3.5) - Verify device_code was issued to the requesting client_id before exchanging tokens (RFC 8628 §3.4) Co-Authored-By: Claude Opus 4.6 --- pkg/devicecode/update.go | 8 ++++++++ pkg/token/device_code.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg/devicecode/update.go b/pkg/devicecode/update.go index fead3992..adbb3e0d 100644 --- a/pkg/devicecode/update.go +++ b/pkg/devicecode/update.go @@ -22,6 +22,14 @@ func DenyDeviceCode(userCode string) error { return err } +func ConsumeDeviceCode(code string) error { + _, err := db.GetDB().Exec( + `UPDATE device_codes SET status = 'consumed' WHERE code = ? AND status = 'authorized'`, + code, + ) + return err +} + func UpdateLastPolledAt(code string, t time.Time) error { _, err := db.GetDB().Exec( `UPDATE device_codes SET last_polled_at = ? WHERE code = ?`, diff --git a/pkg/token/device_code.go b/pkg/token/device_code.go index fedb7c59..90de3b04 100644 --- a/pkg/token/device_code.go +++ b/pkg/token/device_code.go @@ -26,6 +26,12 @@ func handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request, request Token return nil, "", errGrantHandled } + // RFC 8628 §3.4: verify the device_code was issued to this client + if dc.ClientID != request.ClientID { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "device_code was not issued to this client") + return nil, "", errGrantHandled + } + // RFC 8628 §3.5: expired_token if time.Now().After(dc.ExpiresAt) { utils.WriteErrorResponse(w, http.StatusBadRequest, "expired_token", "The device code has expired") @@ -64,6 +70,8 @@ func handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request, request Token utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "User not found") return nil, "", errGrantHandled } + // RFC 8628 §3.5: device code is single-use; mark consumed to prevent replay + _ = devicecode.ConsumeDeviceCode(dc.Code) return usr, dc.Scope, nil default: utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_grant", "Invalid device code status") From 4afb731123dbda4073db9709d4b0c199c9aaae6f Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:52:16 -0700 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20increment=20polling=20interval=20on?= =?UTF-8?q?=20slow=5Fdown=20per=20RFC=208628=20=C2=A73.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each slow_down response now increases the device code's interval by 5 seconds in the database. Also adds Pragma: no-cache header to the device authorization response per RFC 6749 §5.1. Co-Authored-By: Claude Opus 4.6 --- pkg/devicecode/handler.go | 3 ++- pkg/devicecode/update.go | 9 +++++++++ pkg/token/device_code.go | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/devicecode/handler.go b/pkg/devicecode/handler.go index e4267a7b..89687250 100644 --- a/pkg/devicecode/handler.go +++ b/pkg/devicecode/handler.go @@ -112,8 +112,9 @@ func HandleDeviceAuthorization(w http.ResponseWriter, r *http.Request) { Interval: cfg.DeviceCodePollingInterval, } - // RFC 8628 §3.2: response MUST NOT be cached + // RFC 8628 §3.2 / RFC 6749 §5.1: response MUST NOT be cached w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") utils.WriteApiResponse(w, resp, http.StatusOK) } diff --git a/pkg/devicecode/update.go b/pkg/devicecode/update.go index adbb3e0d..e4ba063f 100644 --- a/pkg/devicecode/update.go +++ b/pkg/devicecode/update.go @@ -30,6 +30,15 @@ func ConsumeDeviceCode(code string) error { return err } +// RFC 8628 §3.5: slow_down increments the polling interval by 5 seconds +func IncrementInterval(code string) error { + _, err := db.GetDB().Exec( + `UPDATE device_codes SET interval_seconds = interval_seconds + 5 WHERE code = ?`, + code, + ) + return err +} + func UpdateLastPolledAt(code string, t time.Time) error { _, err := db.GetDB().Exec( `UPDATE device_codes SET last_polled_at = ? WHERE code = ?`, diff --git a/pkg/token/device_code.go b/pkg/token/device_code.go index 90de3b04..3c314ed4 100644 --- a/pkg/token/device_code.go +++ b/pkg/token/device_code.go @@ -44,6 +44,7 @@ func handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request, request Token if elapsed < time.Duration(dc.IntervalSeconds)*time.Second { // RFC 8628 §3.5: slow_down adds 5 seconds to the interval _ = devicecode.UpdateLastPolledAt(dc.Code, time.Now()) + _ = devicecode.IncrementInterval(dc.Code) utils.WriteErrorResponse(w, http.StatusBadRequest, "slow_down", "Polling too frequently") return nil, "", errGrantHandled } From 0dee7a3c625c40a5a5a46af422322fcdfb179e78 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 9 May 2026 16:54:05 -0700 Subject: [PATCH 9/9] docs: add RFC 8628 Device Authorization Grant to rfc/rfc.md Phase 13 compliance table covering all MUST/SHOULD/MAY requirements, security considerations checklist, discovery cross-check, and full test inventory. Co-Authored-By: Claude Opus 4.6 --- rfc/rfc.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/rfc/rfc.md b/rfc/rfc.md index 60a288a0..ba4adaea 100644 --- a/rfc/rfc.md +++ b/rfc/rfc.md @@ -16,6 +16,9 @@ Ten phases tackling one spec at a time, in dependency order. Each phase: read sp | 8 | OIDC RP-Initiated Logout 1.0 | 1.5h | ✅ Done (2026-04-03) | | 9 | RFC 7591 — Dynamic Client Registration | 1.5h | ✅ Done (2026-04-03) | | 10 | RFC 8414 — OAuth 2.0 Authorization Server Metadata | 0.5h | ✅ Done (2026-04-03) | +| 11 | RFC 6749 §4.4 — Client Credentials Grant | 1h | ✅ Done (2026-04-06) | +| 12 | RFC 6819/9700 — Refresh Token Rotation | 1h | ✅ Done (2026-04-07) | +| 13 | RFC 8628 — Device Authorization Grant | 2h | ✅ Done (2026-05-09) | **Recommended order:** 1 → 4 → 5 → 2 → 3 → 6 → 7 → 8 → 9 → 10 @@ -757,3 +760,88 @@ This is analogous to the existing "public endpoints by design" decision for intr - E2e: `TestRefreshToken_ReplayDetection` — replayed rotated token revokes all user tokens, legitimate user must re-authenticate ✅ Added - Functional: `token.test.ts` "rotates refresh token" — old token rejected after rotation ✅ Added - Functional: `token.test.ts` "replay detection revokes all user tokens" — theft mitigation verified ✅ Added + +--- + +## Phase 13 — RFC 8628: Device Authorization Grant + +**File:** `rfc/rfc8628.txt` + +**Context:** Implements the Device Authorization Grant for headless devices and CLI tools. User verification is handled by the account UI (`/account/device`) which leverages existing OIDC authentication (login, MFA, passkeys) instead of reimplementing auth in the device flow. + +| Section | What to check | Code path | +|---|---|---| +| §3.1 | `client_id` REQUIRED, `scope` OPTIONAL, client supports `device_code` grant | `pkg/devicecode/handler.go` `HandleDeviceAuthorization` | +| §3.2 | Response: `device_code`, `user_code`, `verification_uri`, `expires_in` REQUIRED; `verification_uri_complete`, `interval` OPTIONAL; `Cache-Control: no-store` | `pkg/devicecode/handler.go` response construction | +| §3.3 | User verification mechanism — code entry and confirmation | `account-ui/src/pages/Device.tsx`, `pkg/account/device.go` | +| §3.4 | Token request: `grant_type`, `device_code` REQUIRED; `client_id` binding | `pkg/token/device_code.go` `handleDeviceCodeGrant` | +| §3.5 | Polling responses: `authorization_pending`, `slow_down` (+5s interval), `access_denied`, `expired_token` | `pkg/token/device_code.go` status switch | +| §3.5 | Device code is single-use — consumed after successful token exchange | `pkg/token/device_code.go`, `pkg/devicecode/update.go` `ConsumeDeviceCode` | +| §4 | `device_authorization_endpoint` in discovery; `grant_types_supported` includes device_code URN | `pkg/wellknown/handler.go`, `pkg/model/well_known_config.go` | + +**MUST / SHOULD / MAY compliance:** + +| Keyword | Section | Requirement | Status | +|---------|---------|-------------|--------| +| MUST | §3.1 | `client_id` required in device authorization request | ✅ Implemented | +| MUST | §3.1 | Validate client exists and supports `device_code` grant | ✅ Implemented | +| MUST | §3.2 | Response includes `device_code`, `user_code`, `verification_uri`, `expires_in` | ✅ Implemented | +| MUST | §3.2 | Response MUST NOT be cached (`Cache-Control: no-store`, `Pragma: no-cache`) | ✅ Implemented | +| MUST | §3.4 | `grant_type` and `device_code` required in token request | ✅ Implemented | +| MUST | §3.4 | Verify `device_code` was issued to the requesting `client_id` | ✅ Implemented | +| MUST | §3.5 | Return `authorization_pending` while user has not yet responded | ✅ Implemented | +| MUST | §3.5 | Return `slow_down` when polling faster than `interval`; increase interval by 5 seconds | ✅ Implemented | +| MUST | §3.5 | Return `access_denied` when user denies authorization | ✅ Implemented | +| MUST | §3.5 | Return `expired_token` when device code expires | ✅ Implemented | +| MUST | §3.5 | Device code is single-use — reject after successful exchange | ✅ Implemented | +| SHOULD | §3.1 | Validate `scope` against client's allowed scopes | ✅ Implemented | +| SHOULD | §3.1 | Default to client's registered scopes when `scope` omitted | ✅ Implemented | +| OPTIONAL | §3.2 | `verification_uri_complete` with embedded user code | ✅ Implemented (path format: `/account/device/{code}`) | +| OPTIONAL | §3.2 | `interval` in response (default 5 seconds if omitted) | ✅ Implemented | + +**Security Considerations (§5):** +- [x] §5.1: User code entropy — 8 characters from 20-char consonant alphabet (~34.6 bits); sufficient for typed codes with short expiration +- [x] §5.2: User code format — `XXXX-XXXX` with hyphen separator for readability; consonants only (no vowels) to avoid forming offensive words +- [x] §5.3: Non-textual verification URI — `verification_uri_complete` enables QR codes; client can display QR for the full URL +- [x] §5.4: Device code entropy — 160 bits (20 bytes hex via `crypto/rand`); high entropy secret +- [x] §5.5: Rate limiting — `slow_down` enforced with progressive interval increase; per-IP rate limiting on all endpoints +- [x] §5.6: Session spying — user must be authenticated before seeing device info; verification happens in authenticated account UI +- [x] Client binding — device code cannot be exchanged by a different client than the one that requested it + +**Discovery cross-check:** +- [x] `device_authorization_endpoint` present in `/.well-known/openid-configuration` +- [x] `grant_types_supported` includes `"urn:ietf:params:oauth:grant-type:device_code"` + +**Tests:** +- Unit: `TestCreateAndReadDeviceCode` — CRUD lifecycle ✅ +- Unit: `TestDeviceCodeByCode_NotFound` — unknown code ✅ +- Unit: `TestDeviceCodeByUserCode_NotFound` — unknown user code ✅ +- Unit: `TestAuthorizeDeviceCode` — status transition pending → authorized ✅ +- Unit: `TestDenyDeviceCode` — status transition pending → denied ✅ +- Unit: `TestAuthorizeDeviceCode_NotPending` — cannot authorize non-pending code ✅ +- Unit: `TestUpdateLastPolledAt` — polling timestamp update ✅ +- Unit: `TestGenerateDeviceCode` — 40-char hex output ✅ +- Unit: `TestGenerateUserCode` — 8-char consonant code ✅ +- Unit: `TestGenerateUserCode_NoVowels` — no vowels in generated codes ✅ +- Unit: `TestFormatUserCode` — XXXX-XXXX formatting ✅ +- Unit: `TestNormalizeUserCode` — strip hyphens + uppercase ✅ +- Unit: `TestHandleDeviceAuthorization_Success` — happy path, all required fields present ✅ +- Unit: `TestHandleDeviceAuthorization_MissingClientID` — missing client_id → 400 ✅ +- Unit: `TestHandleDeviceAuthorization_UnknownClient` — unknown client → 400 ✅ +- Unit: `TestHandleDeviceAuthorization_GrantTypeNotAllowed` — wrong grant type → 400 ✅ +- Unit: `TestHandleDeviceAuthorization_WrongMethod` — GET rejected ✅ +- Unit: `TestHandleDeviceAuthorization_InvalidScope` — invalid scope → 400 ✅ +- Unit: `TestDeviceCodeGrant_AuthorizationPending` — pending → authorization_pending ✅ +- Unit: `TestDeviceCodeGrant_AccessDenied` — denied → access_denied ✅ +- Unit: `TestDeviceCodeGrant_ExpiredToken` — expired → expired_token ✅ +- Unit: `TestDeviceCodeGrant_Success` — authorized → tokens issued ✅ +- Unit: `TestDeviceCodeGrant_MissingDeviceCode` — missing device_code → invalid_request ✅ +- Functional: `device-grant.test.ts` "issues device_code and user_code" — endpoint response validation ✅ +- Functional: `device-grant.test.ts` "returns authorization_pending when polling" — polling before authorization ✅ +- Functional: `device-grant.test.ts` "rejects client without device_code grant" — unauthorized_client ✅ +- Functional: `device-grant.test.ts` "rejects unknown client_id" — invalid_client ✅ +- Functional: `device-grant.test.ts` "rejects without client_id" — invalid_request ✅ +- Functional: `device-grant.test.ts` "rejects invalid device_code" — invalid_grant ✅ +- Functional: `device-grant.test.ts` "rejects without device_code field" — invalid_request ✅ +- Functional: `device-grant.test.ts` "rejects invalid scope" — invalid_scope ✅ +- Functional: `device-grant.test.ts` "discovery includes device_authorization_endpoint" — discovery verification ✅