From 636018bbf4ecdff4218f22068a45fe28f69f58da Mon Sep 17 00:00:00 2001 From: Andy Doan Date: Thu, 18 Jun 2026 15:42:30 -0500 Subject: [PATCH 1/5] auth: Add a way to expose ratelimiter middleware There are some upcoming functions we need this for when doing oauth2 device flow Signed-off-by: Andy Doan --- auth/noauth.go | 8 ++++++++ auth/provider.go | 2 ++ auth/provider_common.go | 4 ++++ server/ui/api/handlers_test.go | 8 ++++++++ 4 files changed, 22 insertions(+) diff --git a/auth/noauth.go b/auth/noauth.go index 60064637..2e5f23c7 100644 --- a/auth/noauth.go +++ b/auth/noauth.go @@ -69,6 +69,14 @@ func (p noauthProvider) GetSession(c echo.Context) (*Session, error) { func (noauthProvider) DropSession(echo.Context, *Session) { } +func (noauthProvider) GetRateLimiterMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } + } +} + func init() { RegisterProvider(&noauthProvider{}) } diff --git a/auth/provider.go b/auth/provider.go index acb2bd48..c81b4267 100644 --- a/auth/provider.go +++ b/auth/provider.go @@ -38,6 +38,8 @@ type Provider interface { // GetSession retrieves the session associated with the given context. GetSession(c echo.Context) (*Session, error) DropSession(c echo.Context, session *Session) + + GetRateLimiterMiddleware() echo.MiddlewareFunc } const AuthCookieName = "fioserver-session" diff --git a/auth/provider_common.go b/auth/provider_common.go index e68c08b3..1728eb63 100644 --- a/auth/provider_common.go +++ b/auth/provider_common.go @@ -86,3 +86,7 @@ func (p *commonProvider) GetSession(c echo.Context) (*Session, error) { } return nil, p.renderer.renderLoginPage(c, "") } + +func (p *commonProvider) GetRateLimiterMiddleware() echo.MiddlewareFunc { + return p.rateLimiter.Middleware +} diff --git a/server/ui/api/handlers_test.go b/server/ui/api/handlers_test.go index 0c5dedee..b2eccb66 100644 --- a/server/ui/api/handlers_test.go +++ b/server/ui/api/handlers_test.go @@ -215,6 +215,14 @@ func (p testAuthProvider) GetSession(c echo.Context) (*auth.Session, error) { func (testAuthProvider) DropSession(echo.Context, *auth.Session) { } +func (testAuthProvider) GetRateLimiterMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(c) + } + } +} + func NewTestClient(t *testing.T) *testClient { ctx := context.Background() tmpDir := t.TempDir() From 11b7794b44e971ae046a6c4884dff1e6d4b6a46a Mon Sep 17 00:00:00 2001 From: Andy Doan Date: Tue, 27 Jan 2026 16:10:22 -0600 Subject: [PATCH 2/5] storage: Add modelling for oauth2 device-flow Signed-off-by: Andy Doan Assisted-by: GitHub Copilot:claude-4-sonnet --- storage/db.go | 13 ++ storage/users/oauth2.go | 204 +++++++++++++++++++++++++++++ storage/users/user_storage.go | 37 ++++++ storage/users/user_storage_test.go | 117 +++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 storage/users/oauth2.go diff --git a/storage/db.go b/storage/db.go index bab3fdca..27356c99 100644 --- a/storage/db.go +++ b/storage/db.go @@ -165,6 +165,19 @@ func createTables(db *sql.DB) error { uploaded_by TEXT NOT NULL DEFAULT "", PRIMARY KEY (tag, name) ) WITHOUT ROWID; + + CREATE TABLE oauth2_device_flow( + device_code VARCHAR(64) NOT NULL PRIMARY KEY, + user_code VARCHAR(16) NOT NULL UNIQUE, + expires_at INT, + token_expires INT, + token_description VARCHAR(80), + scopes TEXT, + user_id INT, + authorized BOOL DEFAULT 0, + denied BOOL DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES user(id) + ) WITHOUT ROWID; ` if _, err := db.Exec(sqlStmt); err != nil { return fmt.Errorf("unable to create devices db: %w", err) diff --git a/storage/users/oauth2.go b/storage/users/oauth2.go new file mode 100644 index 00000000..9a647ffb --- /dev/null +++ b/storage/users/oauth2.go @@ -0,0 +1,204 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package users + +import ( + "crypto/rand" + "database/sql" + + "github.com/foundriesio/update-server/storage" +) + +// OAuth2DeviceAuth represents an OAuth2 device-flow authorization request +type OAuth2DeviceAuth struct { + DeviceCode string + UserCode string + ExpiresAt int64 + TokenExpires int64 // What time the issued token should expire + TokenDescription string // Description to be stored with the issued token + Scopes string // The scopes to be assigned to the token + UserID *int64 + Authorized bool + Denied bool +} + +func (s Storage) CreateDeviceAuth(expiresAt, tokenExpires int64, scopes string) (string, string, error) { + deviceCode := rand.Text() + userCode := rand.Text() + userCode = userCode[:4] + "-" + userCode[4:8] + return deviceCode, userCode, s.stmtOAuth2DeviceAuthCreate.run(deviceCode, userCode, expiresAt, tokenExpires, scopes) +} + +func (s Storage) GetDeviceAuthByDeviceCode(deviceCode string) (*OAuth2DeviceAuth, error) { + return s.stmtOAuth2DeviceAuthGetByDeviceCode.run(deviceCode) +} + +func (s Storage) GetDeviceAuthByUserCode(userCode string) (*OAuth2DeviceAuth, error) { + return s.stmtOAuth2DeviceAuthGetByUserCode.run(userCode) +} + +func (s Storage) DeleteExpiredDeviceAuth(before int64) error { + return s.stmtOAuth2DeviceAuthDeleteExpired.run(before) +} + +func (s Storage) DeleteDeviceAuth(deviceCode string) error { + return s.stmtOAuth2DeviceAuthDelete.run(deviceCode) +} + +type stmtOAuth2DeviceAuthCreate storage.DbStmt + +func (s *stmtOAuth2DeviceAuthCreate) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthCreate", ` + INSERT INTO oauth2_device_flow (device_code, user_code, expires_at, token_expires, token_description, scopes) + VALUES (?, ?, ?, ?, "", ?)`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthCreate) run(deviceCode, userCode string, expiresAt, tokenExpires int64, scopes string) error { + _, err := s.Stmt.Exec(deviceCode, userCode, expiresAt, tokenExpires, scopes) + return err +} + +type stmtOAuth2DeviceAuthGetByDeviceCode storage.DbStmt + +func (s *stmtOAuth2DeviceAuthGetByDeviceCode) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthGetByDeviceCode", ` + SELECT device_code, user_code, expires_at, token_expires, token_description, scopes, user_id, authorized, denied + FROM oauth2_device_flow + WHERE device_code = ?`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthGetByDeviceCode) run(deviceCode string) (*OAuth2DeviceAuth, error) { + auth := &OAuth2DeviceAuth{} + var userID sql.NullInt64 + + err := s.Stmt.QueryRow(deviceCode).Scan( + &auth.DeviceCode, + &auth.UserCode, + &auth.ExpiresAt, + &auth.TokenExpires, + &auth.TokenDescription, + &auth.Scopes, + &userID, + &auth.Authorized, + &auth.Denied, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if userID.Valid { + auth.UserID = &userID.Int64 + } + + return auth, nil +} + +type stmtOAuth2DeviceAuthGetByUserCode storage.DbStmt + +func (s *stmtOAuth2DeviceAuthGetByUserCode) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthGetByUserCode", ` + SELECT device_code, user_code, expires_at, token_expires, token_description, scopes, user_id, authorized, denied + FROM oauth2_device_flow + WHERE user_code = ?`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthGetByUserCode) run(userCode string) (*OAuth2DeviceAuth, error) { + auth := &OAuth2DeviceAuth{} + var userID sql.NullInt64 + + err := s.Stmt.QueryRow(userCode).Scan( + &auth.DeviceCode, + &auth.UserCode, + &auth.ExpiresAt, + &auth.TokenExpires, + &auth.TokenDescription, + &auth.Scopes, + &userID, + &auth.Authorized, + &auth.Denied, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if userID.Valid { + auth.UserID = &userID.Int64 + } + + return auth, nil +} + +type stmtOAuth2DeviceAuthAuthorize storage.DbStmt + +func (s *stmtOAuth2DeviceAuthAuthorize) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthAuthorize", ` + UPDATE oauth2_device_flow + SET user_id = ?, authorized = 1, token_description = ?, scopes = ? + WHERE device_code = ? AND authorized = 0 AND denied = 0`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthAuthorize) run(userID int64, deviceCode, tokenDescription, scopes string) error { + _, err := s.Stmt.Exec(userID, tokenDescription, scopes, deviceCode) + return err +} + +type stmtOAuth2DeviceAuthDeny storage.DbStmt + +func (s *stmtOAuth2DeviceAuthDeny) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthDeny", ` + UPDATE oauth2_device_flow + SET denied = 1 + WHERE device_code = ? AND authorized = 0 AND denied = 0`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthDeny) run(deviceCode string) error { + _, err := s.Stmt.Exec(deviceCode) + return err +} + +type stmtOAuth2DeviceAuthDelete storage.DbStmt + +func (s *stmtOAuth2DeviceAuthDelete) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthDelete", ` + DELETE FROM oauth2_device_flow + WHERE device_code = ?`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthDelete) run(deviceCode string) error { + _, err := s.Stmt.Exec(deviceCode) + return err +} + +type stmtOAuth2DeviceAuthDeleteExpired storage.DbStmt + +func (s *stmtOAuth2DeviceAuthDeleteExpired) Init(db storage.DbHandle) (err error) { + s.Stmt, err = db.Prepare("oauth2DeviceAuthDeleteExpired", ` + DELETE FROM oauth2_device_flow + WHERE expires_at < ?`, + ) + return +} + +func (s *stmtOAuth2DeviceAuthDeleteExpired) run(before int64) error { + _, err := s.Stmt.Exec(before) + return err +} diff --git a/storage/users/user_storage.go b/storage/users/user_storage.go index 1d7f3281..2231a3f6 100644 --- a/storage/users/user_storage.go +++ b/storage/users/user_storage.go @@ -48,6 +48,22 @@ func (u User) GetAuditLog() (string, error) { return u.h.fs.Audit.ReadEvents(u.id) } +func (u User) ApproveAuthorization(deviceCode string, tokenDescription string, scopes Scopes) error { + if err := u.h.stmtOAuth2DeviceAuthAuthorize.run(u.id, deviceCode, tokenDescription, scopes.String()); err != nil { + return err + } + u.h.fs.Audit.AppendEvent(u.id, "OAuth2 device authorized: "+tokenDescription) + return nil +} + +func (u User) DenyDeviceAuth(deviceCode string) error { + if err := u.h.stmtOAuth2DeviceAuthDeny.run(deviceCode); err != nil { + return err + } + u.h.fs.Audit.AppendEvent(u.id, "OAuth2 device denied") + return nil +} + type Storage struct { db *storage.DbHandle fs *storage.FsHandle @@ -71,6 +87,15 @@ type Storage struct { stmtTokenDeleteExpired stmtTokenDeleteExpired stmtTokenList stmtTokenList stmtTokenLookup stmtTokenLookup + + // OAuth2 device-flow authorization + stmtOAuth2DeviceAuthCreate stmtOAuth2DeviceAuthCreate + stmtOAuth2DeviceAuthGetByDeviceCode stmtOAuth2DeviceAuthGetByDeviceCode + stmtOAuth2DeviceAuthGetByUserCode stmtOAuth2DeviceAuthGetByUserCode + stmtOAuth2DeviceAuthAuthorize stmtOAuth2DeviceAuthAuthorize + stmtOAuth2DeviceAuthDeny stmtOAuth2DeviceAuthDeny + stmtOAuth2DeviceAuthDelete stmtOAuth2DeviceAuthDelete + stmtOAuth2DeviceAuthDeleteExpired stmtOAuth2DeviceAuthDeleteExpired } func NewStorage(db *storage.DbHandle, fs *storage.FsHandle) (*Storage, error) { @@ -100,6 +125,13 @@ func NewStorage(db *storage.DbHandle, fs *storage.FsHandle) (*Storage, error) { &handle.stmtTokenDeleteExpired, &handle.stmtTokenList, &handle.stmtTokenLookup, + &handle.stmtOAuth2DeviceAuthCreate, + &handle.stmtOAuth2DeviceAuthGetByDeviceCode, + &handle.stmtOAuth2DeviceAuthGetByUserCode, + &handle.stmtOAuth2DeviceAuthAuthorize, + &handle.stmtOAuth2DeviceAuthDeny, + &handle.stmtOAuth2DeviceAuthDelete, + &handle.stmtOAuth2DeviceAuthDeleteExpired, ); err != nil { return nil, err } @@ -118,6 +150,11 @@ func (s Storage) RunGc() { if err := s.stmtSessionDeleteExpired.run(now); err != nil { slog.Error("Unable to run user session GC", "error", err) } + + slog.Info("Running OAuth2 device-flow GC") + if err := s.stmtOAuth2DeviceAuthDeleteExpired.run(now); err != nil { + slog.Error("Unable to run OAuth2 device-flow GC", "error", err) + } } func (s Storage) Create(u *User) error { diff --git a/storage/users/user_storage_test.go b/storage/users/user_storage_test.go index 281d7bab..f4651df1 100644 --- a/storage/users/user_storage_test.go +++ b/storage/users/user_storage_test.go @@ -220,3 +220,120 @@ func TestGc(t *testing.T) { require.Nil(t, err) require.Nil(t, u2) } + +func TestOAuth2DeviceFlow(t *testing.T) { + tmpdir := t.TempDir() + dbFile := filepath.Join(tmpdir, "sql.db") + db, err := storage.NewDb(dbFile) + require.Nil(t, err) + fs, err := storage.NewFs(tmpdir) + require.Nil(t, err) + require.Nil(t, fs.Auth.InitHmacSecret()) + + users, err := NewStorage(db, fs) + require.Nil(t, err) + require.NotNil(t, users) + + u := User{ + Username: "testuser", + Password: "passwordhash", + Email: "testuser@example.com", + AllowedScopes: ScopeDevicesRU, + } + err = users.Create(&u) + require.Nil(t, err) + + // Test creating device authorization + now := time.Now().Unix() + expiresAt := now + 600 // 10 minutes + tokenExpires := now + 3600 // 1 hour + scopes := "devices:read" + + deviceCode, userCode, err := users.CreateDeviceAuth(expiresAt, tokenExpires, scopes) + require.Nil(t, err) + + // Test getting device auth by device code + auth, err := users.GetDeviceAuthByDeviceCode(deviceCode) + require.Nil(t, err) + require.NotNil(t, auth) + require.Equal(t, deviceCode, auth.DeviceCode) + require.Equal(t, userCode, auth.UserCode) + require.Equal(t, expiresAt, auth.ExpiresAt) + require.Equal(t, tokenExpires, auth.TokenExpires) + require.Equal(t, scopes, auth.Scopes) + require.Nil(t, auth.UserID) + require.False(t, auth.Authorized) + require.False(t, auth.Denied) + + // Test getting device auth by user code + auth2, err := users.GetDeviceAuthByUserCode(userCode) + require.Nil(t, err) + require.NotNil(t, auth2) + require.Equal(t, deviceCode, auth2.DeviceCode) + require.Equal(t, userCode, auth2.UserCode) + + // Test getting non-existent device auth + auth3, err := users.GetDeviceAuthByDeviceCode("nonexistent") + require.Nil(t, err) + require.Nil(t, auth3) + + auth4, err := users.GetDeviceAuthByUserCode("ZZZZ-ZZZZ") + require.Nil(t, err) + require.Nil(t, auth4) + + // Test approving authorization + err = u.ApproveAuthorization(deviceCode, "test token description", ScopeDevicesR) + require.Nil(t, err) + + auth5, err := users.GetDeviceAuthByDeviceCode(deviceCode) + require.Nil(t, err) + require.NotNil(t, auth5) + require.True(t, auth5.Authorized) + require.False(t, auth5.Denied) + require.NotNil(t, auth5.UserID) + require.Equal(t, u.id, *auth5.UserID) + require.Equal(t, "test token description", auth5.TokenDescription) + + // Create another device auth for deny test + deviceCode2, _, err := users.CreateDeviceAuth(expiresAt, tokenExpires, scopes) + require.Nil(t, err) + + // Test denying authorization + err = u.DenyDeviceAuth(deviceCode2) + require.Nil(t, err) + + auth6, err := users.GetDeviceAuthByDeviceCode(deviceCode2) + require.Nil(t, err) + require.NotNil(t, auth6) + require.False(t, auth6.Authorized) + require.True(t, auth6.Denied) + require.Nil(t, auth6.UserID) + + // Test deleting expired device auth entries + expiredExpiresAt := now - 600 + deviceCode3, _, err := users.CreateDeviceAuth(expiredExpiresAt, tokenExpires, scopes) + require.Nil(t, err) + + // Verify it exists + auth7, err := users.GetDeviceAuthByDeviceCode(deviceCode3) + require.Nil(t, err) + require.NotNil(t, auth7) + + // Delete expired entries + err = users.DeleteExpiredDeviceAuth(now) + require.Nil(t, err) + + // Verify expired entry is gone + auth8, err := users.GetDeviceAuthByDeviceCode(deviceCode3) + require.Nil(t, err) + require.Nil(t, auth8) + + // Verify non-expired entries still exist + auth9, err := users.GetDeviceAuthByDeviceCode(deviceCode) + require.Nil(t, err) + require.NotNil(t, auth9) + + auth10, err := users.GetDeviceAuthByDeviceCode(deviceCode2) + require.Nil(t, err) + require.NotNil(t, auth10) +} From 3182c7948a207ec8c8d271a3cc5eca92c04c58a9 Mon Sep 17 00:00:00 2001 From: Andy Doan Date: Mon, 26 Jan 2026 09:49:53 -0600 Subject: [PATCH 3/5] Add API/UI for oauth2 device flow Signed-off-by: Andy Doan Assisted-by: GitHub Copilot:claude-4-sonnet --- auth/csrf.go | 4 + server/ui/api/handlers.go | 14 +- server/ui/api/handlers_oauth2.go | 215 ++++++++++++++++++ server/ui/api/handlers_test.go | 6 +- server/ui/server.go | 2 +- server/ui/web/handlers.go | 5 + server/ui/web/handlers_oauth2.go | 164 +++++++++++++ server/ui/web/templates/device_auth.html | 16 ++ .../ui/web/templates/device_auth_confirm.html | 36 +++ .../ui/web/templates/device_auth_success.html | 7 + storage/users/user_storage.go | 11 + 11 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 server/ui/api/handlers_oauth2.go create mode 100644 server/ui/web/handlers_oauth2.go create mode 100644 server/ui/web/templates/device_auth.html create mode 100644 server/ui/web/templates/device_auth_confirm.html create mode 100644 server/ui/web/templates/device_auth_success.html diff --git a/auth/csrf.go b/auth/csrf.go index d5b86fa5..2d9382db 100644 --- a/auth/csrf.go +++ b/auth/csrf.go @@ -46,6 +46,10 @@ func CsrfCheck(next echo.HandlerFunc) echo.HandlerFunc { return next(c) } + if c.Path() == "/oauth2/device/code" || c.Path() == "/oauth2/device/token" { + return next(c) + } + cookie, err := c.Cookie(CsrfCookieName) if err != nil || cookie.Value == "" { return c.String(http.StatusForbidden, "Missing CSRF cookie") diff --git a/server/ui/api/handlers.go b/server/ui/api/handlers.go index 8c9cbea7..ac7ea2dc 100644 --- a/server/ui/api/handlers.go +++ b/server/ui/api/handlers.go @@ -15,12 +15,22 @@ import ( type handlers struct { storage *storage.Storage + users *users.Storage } var EchoError = server.EchoError -func RegisterHandlers(e *echo.Echo, storage *storage.Storage, a auth.Provider) { - h := handlers{storage: storage} +func RegisterHandlers(e *echo.Echo, storage *storage.Storage, userStorage *users.Storage, a auth.Provider) { + h := handlers{ + storage: storage, + users: userStorage, + } + + // OAuth2 endpoints (no authentication required) + oauth2 := oauth2Handlers{users: userStorage} + e.POST("/oauth2/device/code", oauth2.oauth2DeviceCode, a.GetRateLimiterMiddleware()) + e.POST("/oauth2/device/token", oauth2.oauth2DeviceToken, a.GetRateLimiterMiddleware()) + g := e.Group("/v1") g.Use(authUser(a)) diff --git a/server/ui/api/handlers_oauth2.go b/server/ui/api/handlers_oauth2.go new file mode 100644 index 00000000..1c69f8d2 --- /dev/null +++ b/server/ui/api/handlers_oauth2.go @@ -0,0 +1,215 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" + + "github.com/foundriesio/update-server/storage/users" +) + +type oauth2Handlers struct { + users *users.Storage +} + +type DeviceCodeRequest struct { + Scopes string `json:"scope" form:"scope"` // backward compatible with lmp-device-register + TokenExpires int64 `json:"token_expires" form:"token_expires"` +} + +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` // seconds until expiry, per RFC 8628 + Interval int `json:"interval"` +} + +type DeviceTokenRequest struct { + DeviceCode string `json:"device_code"` + GrantType string `json:"grant_type"` +} + +type DeviceTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Expires int64 `json:"expires"` + Scopes string `json:"scope"` +} + +type oauth2Error struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// @Summary Initiate OAuth2 Device Authorization +// @Accept json +// @Param data body DeviceCodeRequest true "Device code request" +// @Produce json +// @Success 200 +// @Router /oauth2/device/code [post] +func (h oauth2Handlers) oauth2DeviceCode(c echo.Context) error { + var req DeviceCodeRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_request", + ErrorDescription: "Invalid request body", + }) + } + + _, err := users.ScopesFromString(req.Scopes) + if err != nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_scope", + ErrorDescription: fmt.Sprintf("Invalid scopes: %v", err), + }) + } + + now := time.Now() + if req.TokenExpires <= now.Unix() { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_request", + ErrorDescription: "token_expires must be in the future", + }) + } + if req.TokenExpires > now.Add(365*24*time.Hour).Unix() { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_request", + ErrorDescription: "token_expires must be within one year", + }) + } + + const deviceCodeTTL = 10 * time.Minute + expires := time.Now().Add(deviceCodeTTL).Unix() + deviceCode, userCode, err := h.users.CreateDeviceAuth(expires, req.TokenExpires, req.Scopes) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to create device authorization") + } + + baseURL := c.Scheme() + "://" + c.Request().Host + verificationURI := baseURL + "/auth/activate" + verificationURIComplete := fmt.Sprintf("%s?user_code=%s", verificationURI, userCode) + + return c.JSON(http.StatusOK, DeviceCodeResponse{ + DeviceCode: deviceCode, + UserCode: userCode, + VerificationURI: verificationURI, + VerificationURIComplete: verificationURIComplete, + ExpiresIn: int(deviceCodeTTL.Seconds()), + Interval: 5, // Poll every 5 seconds + }) +} + +// @Summary Handle OAuth2 Device Token Polling +// @Accept json +// @Param data body DeviceTokenRequest true "Device token request" +// @Produce json +// @Success 200 +// @Router /oauth2/device/token [post] +func (h oauth2Handlers) oauth2DeviceToken(c echo.Context) error { + var req DeviceTokenRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_request", + ErrorDescription: "Invalid request body", + }) + } + + if req.GrantType != "urn:ietf:params:oauth:grant-type:device_code" { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "unsupported_grant_type", + ErrorDescription: "Only device_code grant type is supported", + }) + } + + if req.DeviceCode == "" { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_request", + ErrorDescription: "device_code is required", + }) + } + + auth, err := h.users.GetDeviceAuthByDeviceCode(req.DeviceCode) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to get authorization") + } + if auth == nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_grant", + ErrorDescription: "Invalid device code", + }) + } + + if auth.ExpiresAt < time.Now().Unix() { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_grant", + ErrorDescription: "Invalid device code", + }) + } + + if auth.Denied { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "access_denied", + ErrorDescription: "User denied the authorization request", + }) + } + + if !auth.Authorized { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "authorization_pending", + ErrorDescription: "User has not yet authorized this device", + }) + } + + if auth.UserID == nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_grant", + ErrorDescription: "No user associated with this authorization", + }) + } + + user, err := h.users.GetByID(*auth.UserID) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to get user for authorization") + } + if user == nil { + return c.JSON(http.StatusBadRequest, oauth2Error{ + Error: "invalid_grant", + ErrorDescription: "User for authorization not found", + }) + } + + scopes, err := users.ScopesFromString(auth.Scopes) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to parse scopes") + } + + if len(auth.TokenDescription) == 0 { + auth.TokenDescription = "OAuth2 authorization" + } + scopes = user.AllowedScopes & scopes + + // Delete before generating: if generation fails the user must re-authorize, + // but we avoid issuing a second token if deletion fails after a successful insert. + if err := h.users.DeleteDeviceAuth(auth.DeviceCode); err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to consume device authorization") + } + + token, err := user.GenerateToken(auth.TokenDescription, auth.TokenExpires, scopes) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to generate token") + } + + return c.JSON(http.StatusOK, DeviceTokenResponse{ + AccessToken: token.Value, + TokenType: "Bearer", + Expires: auth.TokenExpires, + Scopes: scopes.String(), + }) +} diff --git a/server/ui/api/handlers_test.go b/server/ui/api/handlers_test.go index b2eccb66..f730af21 100644 --- a/server/ui/api/handlers_test.go +++ b/server/ui/api/handlers_test.go @@ -228,12 +228,15 @@ func NewTestClient(t *testing.T) *testClient { tmpDir := t.TempDir() fsS, err := apiStorage.NewFs(tmpDir) require.Nil(t, err) + require.Nil(t, fsS.Auth.InitHmacSecret()) db, err := apiStorage.NewDb(filepath.Join(tmpDir, apiStorage.DbFile)) require.Nil(t, err) apiS, err := apiStorage.NewStorage(db, fsS) require.Nil(t, err) gwS, err := gatewayStorage.NewStorage(db, fsS) require.Nil(t, err) + userS, err := users.NewStorage(db, fsS) + require.Nil(t, err) log, err := context.InitLogger("debug") require.Nil(t, err) @@ -245,7 +248,7 @@ func NewTestClient(t *testing.T) *testClient { Username: "root", AllowedScopes: 0, } - RegisterHandlers(e, apiS, &testAuthProvider{user: u}) + RegisterHandlers(e, apiS, userS, &testAuthProvider{user: u}) tc := testClient{ t: t, @@ -806,7 +809,6 @@ func TestApiRolloutPut(t *testing.T) { func TestApiRolloutDaemon(t *testing.T) { tc := NewTestClient(t) - require.Nil(t, tc.fs.Auth.InitHmacSecret()) db, err := apiStorage.NewDb(filepath.Join(t.TempDir(), apiStorage.DbFile)) require.Nil(t, err) usersS, err := users.NewStorage(db, tc.fs) diff --git a/server/ui/server.go b/server/ui/server.go index 893a0a47..e0dc172e 100644 --- a/server/ui/server.go +++ b/server/ui/server.go @@ -47,7 +47,7 @@ func NewServer(ctx context.Context, db *storage.DbHandle, fs *storage.FsHandle, srv := server.NewServer(ctx, e, serverName, bindAddr, nil) e.Use(auth.CsrfCheck) - apiHandlers.RegisterHandlers(e, strg, provider) + apiHandlers.RegisterHandlers(e, strg, users, provider) webHandlers.RegisterHandlers(e, users, provider) return &apiServer{server: srv, daemons: daemons}, nil } diff --git a/server/ui/web/handlers.go b/server/ui/web/handlers.go index 597037c9..2814f4f1 100644 --- a/server/ui/web/handlers.go +++ b/server/ui/web/handlers.go @@ -41,6 +41,11 @@ func RegisterHandlers(e *echo.Echo, storage *users.Storage, authProvider auth.Pr e.GET("/", h.index, h.requireSession) e.GET("/css/:filename", h.css) + rateLimiter := authProvider.GetRateLimiterMiddleware() + e.GET("/auth/activate", h.authDevice, h.requireSession) + e.GET("/auth/confirm-activation", h.authDeviceConfirm, h.requireSession, rateLimiter) + e.POST("/auth/authorize-activation", h.authDeviceAuthorize, h.requireSession, rateLimiter) + e.POST("/auth/deny-activation", h.authDeviceDeny, h.requireSession, rateLimiter) e.GET("/auth/logout", h.authLogout, h.requireSession) e.GET("/configs", h.configsList, h.requireSession, h.requireScope(users.ScopeDevicesR)) e.GET("/configs/device/:uuid", h.configsDeviceItem, h.requireSession, h.requireScope(users.ScopeDevicesR)) diff --git a/server/ui/web/handlers_oauth2.go b/server/ui/web/handlers_oauth2.go new file mode 100644 index 00000000..9c5fa493 --- /dev/null +++ b/server/ui/web/handlers_oauth2.go @@ -0,0 +1,164 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package web + +import ( + "errors" + "net/http" + "time" + + "github.com/labstack/echo/v4" + + "github.com/foundriesio/update-server/storage/users" +) + +func (h handlers) authDevice(c echo.Context) error { + userCode := c.QueryParam("user_code") + + data := struct { + baseCtx + UserCode string + }{ + baseCtx: h.baseCtx(c, "API Activation", "settings"), + UserCode: userCode, + } + + return c.Render(http.StatusOK, "device_auth.html", data) +} + +var errDeviceAuthInvalidCode = errors.New("invalid user code") +var errDeviceAuthExpired = errors.New("authorization code expired") +var errDeviceAuthAlreadyHandled = errors.New("this device has already been authorized or denied") + +func (h handlers) authDeviceConfirm(c echo.Context) error { + userCode := c.QueryParam("user_code") + if userCode == "" { + return EchoError(c, nil, http.StatusBadRequest, "user_code is required") + } + + auth, err := h.users.GetDeviceAuthByUserCode(userCode) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to get device authorization") + } + if auth == nil { + return EchoError(c, errDeviceAuthInvalidCode, http.StatusNotFound, errDeviceAuthInvalidCode.Error()) + } + + if auth.ExpiresAt < time.Now().Unix() { + return EchoError(c, errDeviceAuthExpired, http.StatusBadRequest, errDeviceAuthExpired.Error()) + } + if auth.Authorized || auth.Denied { + return EchoError(c, errDeviceAuthAlreadyHandled, http.StatusBadRequest, errDeviceAuthAlreadyHandled.Error()) + } + + // Get the current user from session to intersect scopes + session := CtxGetSession(c.Request().Context()) + if session == nil || session.User == nil { + return EchoError(c, nil, http.StatusUnauthorized, "Not authenticated") + } + + scopes, err := users.ScopesFromString(auth.Scopes) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to parse requested scopes: "+auth.Scopes) + } + allowedScopes := scopes & session.User.AllowedScopes + + data := struct { + baseCtx + UserCode string + Scopes string + TokenExpires int64 + }{ + baseCtx: h.baseCtx(c, "API Activation", "settings"), + UserCode: userCode, + Scopes: allowedScopes.String(), + TokenExpires: auth.TokenExpires, + } + + return c.Render(http.StatusOK, "device_auth_confirm.html", data) +} + +func (h handlers) authDeviceAuthorize(c echo.Context) error { + userCode := c.FormValue("user_code") + if userCode == "" { + return EchoError(c, nil, http.StatusBadRequest, "user_code is required") + } + description := c.FormValue("token_description") + + auth, err := h.users.GetDeviceAuthByUserCode(userCode) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to get device authorization") + } + if auth == nil { + return EchoError(c, errDeviceAuthInvalidCode, http.StatusNotFound, errDeviceAuthInvalidCode.Error()) + } + + if auth.ExpiresAt < time.Now().Unix() { + return EchoError(c, errDeviceAuthExpired, http.StatusBadRequest, errDeviceAuthExpired.Error()) + } + + if auth.Authorized || auth.Denied { + return EchoError(c, errDeviceAuthAlreadyHandled, http.StatusBadRequest, errDeviceAuthAlreadyHandled.Error()) + } + + session := CtxGetSession(c.Request().Context()) + + scopes, err := users.ScopesFromString(auth.Scopes) + if err != nil { + return EchoError(c, err, http.StatusInternalServerError, "Failed to parse requested scopes: "+auth.Scopes) + } + allowedScopes := scopes & session.User.AllowedScopes + + if err := session.User.ApproveAuthorization(auth.DeviceCode, description, allowedScopes); err != nil { + return h.handleUnexpected(c, err) + } + + data := struct { + baseCtx + Message string + }{ + baseCtx: h.baseCtx(c, "API Activation", "settings"), + Message: "Activation successful! You can close this window and return to your device.", + } + return c.Render(http.StatusOK, "device_auth_success.html", data) +} + +// authDeviceDeny handles the user denying the device authorization +// POST /auth/device/deny +func (h handlers) authDeviceDeny(c echo.Context) error { + userCode := c.FormValue("user_code") + if userCode == "" { + return EchoError(c, nil, http.StatusBadRequest, "user_code is required") + } + + auth, err := h.users.GetDeviceAuthByUserCode(userCode) + if err != nil { + return h.handleError(c, http.StatusInternalServerError, errors.New("failed to get device authorization")) + } + if auth == nil { + return h.handleError(c, http.StatusNotFound, errDeviceAuthInvalidCode) + } + + if auth.ExpiresAt < time.Now().Unix() { + return h.handleError(c, http.StatusBadRequest, errDeviceAuthExpired) + } + + if auth.Authorized || auth.Denied { + return h.handleError(c, http.StatusBadRequest, errDeviceAuthAlreadyHandled) + } + + session := CtxGetSession(c.Request().Context()) + if err := session.User.DenyDeviceAuth(auth.DeviceCode); err != nil { + return h.handleUnexpected(c, err) + } + + data := struct { + baseCtx + Message string + }{ + baseCtx: h.baseCtx(c, "API Activation", "settings"), + Message: "Activation denied. You can close this window.", + } + return c.Render(http.StatusOK, "device_auth_success.html", data) +} diff --git a/server/ui/web/templates/device_auth.html b/server/ui/web/templates/device_auth.html new file mode 100644 index 00000000..1158260e --- /dev/null +++ b/server/ui/web/templates/device_auth.html @@ -0,0 +1,16 @@ +{{ template "header" .}} +
+

Activate

+

A user or device is requesting access to your account. Please enter the code shown on your device.

+ +
+ + + +
+
+ +{{ template "footer"}} diff --git a/server/ui/web/templates/device_auth_confirm.html b/server/ui/web/templates/device_auth_confirm.html new file mode 100644 index 00000000..dfd80af1 --- /dev/null +++ b/server/ui/web/templates/device_auth_confirm.html @@ -0,0 +1,36 @@ +{{ template "header" .}} +
+

Activate

+

A user or device is requesting access to your account with the following details:

+ +
+
Request
+ + + + + + + + + + + + + +
User code:{{.UserCode}}
Requested scopes:{{if .Scopes}}{{.Scopes}}{{else}}All available scopes{{end}}
Access token would expire:{{tsToString .TokenExpires}}
+
+ +

Do you want to authorize this access to your account?

+ +
+ + + + + + +
+
+ +{{ template "footer"}} diff --git a/server/ui/web/templates/device_auth_success.html b/server/ui/web/templates/device_auth_success.html new file mode 100644 index 00000000..725850cb --- /dev/null +++ b/server/ui/web/templates/device_auth_success.html @@ -0,0 +1,7 @@ +{{ template "header" .}} +
+

Activation Complete

+

{{.Message}}

+
+ +{{ template "footer"}} diff --git a/storage/users/user_storage.go b/storage/users/user_storage.go index 2231a3f6..fa73ce18 100644 --- a/storage/users/user_storage.go +++ b/storage/users/user_storage.go @@ -193,6 +193,17 @@ func (s Storage) Get(username string) (*User, error) { return u, err } +func (s Storage) GetByID(id int64) (*User, error) { + u, err := s.stmtUserGetById.run(id) + switch err { + case sql.ErrNoRows: + return nil, nil + case nil: + u.h = s + } + return u, err +} + func (s Storage) List() ([]User, error) { users, err := s.stmtUserList.run() if err == nil { From 5e4bfeb8478f5d6c28c4ecaaead20b10ca63f4ca Mon Sep 17 00:00:00 2001 From: Andy Doan Date: Mon, 26 Jan 2026 12:48:00 -0600 Subject: [PATCH 4/5] cli: Add oauth2 device flow for `login` command Signed-off-by: Andy Doan Assisted-by: GitHub Copilot:claude-4-sonnet --- cli/subcommands/login/cmd.go | 156 +++++++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/cli/subcommands/login/cmd.go b/cli/subcommands/login/cmd.go index e7878732..0c5854ef 100644 --- a/cli/subcommands/login/cmd.go +++ b/cli/subcommands/login/cmd.go @@ -4,12 +4,18 @@ package login import ( + "bytes" + "encoding/json" "fmt" + "io" + "net/http" "os" + "time" "github.com/spf13/cobra" "github.com/foundriesio/update-server/cli/config" + models "github.com/foundriesio/update-server/server/ui/api" ) var LoginCmd = &cobra.Command{ @@ -27,23 +33,32 @@ the configuration to ~/.config/satcli.yaml.`, token, _ := cmd.Flags().GetString("token") setDefault, _ := cmd.Flags().GetBool("set-default") configPath, _ := cmd.Flags().GetString("config") + scopes, _ := cmd.Flags().GetString("scopes") + expiresInDays, _ := cmd.Flags().GetInt("expires-in-days") - cobra.CheckErr(login(contextName, serverURL, token, configPath, setDefault)) + cobra.CheckErr(login(configPath, contextName, serverURL, token, scopes, expiresInDays, setDefault)) }, } func init() { - LoginCmd.Flags().String("token", "", "API token for authentication (required for now)") + LoginCmd.Flags().String("token", "", "API token for authentication (skips OAuth2 device flow)") LoginCmd.Flags().Bool("set-default", true, "Set this context as the default") LoginCmd.Flags().String("config", "", "Specify the configuration file to use") - cobra.CheckErr(LoginCmd.MarkFlagRequired("token")) + LoginCmd.Flags().String("scopes", "devices:read-update,updates:read-update", "Comma-separated list of OAuth2 scopes to request (optional)") + LoginCmd.Flags().Int("expires-in-days", 90, "Number of days until the access token expires") } -func login(contextName, serverURL, token, configPath string, setDefault bool) error { - if token == "" { - return fmt.Errorf("--token is required") +func login(configPath, contextName, serverURL, token, scopes string, expiresInDays int, setDefault bool) error { + if token != "" { + return saveToken(configPath, contextName, serverURL, token, setDefault) } + fmt.Println("Initiating OAuth2 device authorization flow...") + expires := time.Now().Add(time.Duration(expiresInDays) * 24 * time.Hour).Unix() + return oauth2DeviceFlow(configPath, contextName, serverURL, scopes, expires, setDefault) +} + +func saveToken(configPath, contextName, serverURL, token string, setDefault bool) error { // Load existing config or create new one cfg, err := config.LoadConfig(configPath) if err != nil { @@ -80,3 +95,132 @@ func login(contextName, serverURL, token, configPath string, setDefault bool) er return nil } + +type oauth2Error struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +func (e *oauth2Error) Error() string { + if e.ErrorDescription != "" { + return fmt.Sprintf("%s: %s", e.ErrorCode, e.ErrorDescription) + } + return e.ErrorCode +} + +func oauth2DeviceFlow(configPath, contextName, serverURL, scopes string, expires int64, setDefault bool) error { + // Step 1: Request device code + codeReq := models.DeviceCodeRequest{ + Scopes: scopes, + TokenExpires: expires, + } + jsonData, err := json.Marshal(codeReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := http.Post(serverURL+"/oauth2/device/code", "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to request device code: %w", err) + } + defer resp.Body.Close() // nolint:errcheck + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to get device code (status %d): %s", resp.StatusCode, string(body)) + } + + var codeResp models.DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { + return fmt.Errorf("failed to decode device code response: %w", err) + } + + // Step 2: Display user code and verification URI + fmt.Println() + fmt.Println("------------------------------------------------") + fmt.Printf(" Visit: %s\n", codeResp.VerificationURI) + fmt.Println() + fmt.Printf(" Enter code: %s\n", codeResp.UserCode) + fmt.Println("------------------------------------------------") + fmt.Println() + fmt.Println("Waiting for authorization...") + + // Step 3: Poll for token + pollInterval := time.Duration(codeResp.Interval) * time.Second + expiresAt := time.Now().Add(time.Duration(codeResp.ExpiresIn) * time.Second) + + for time.Now().Before(expiresAt) { + time.Sleep(pollInterval) + + token, err := pollForToken(serverURL, codeResp.DeviceCode) + if err == nil { + // Success! Save the token + fmt.Println() + fmt.Println("✓ Authorization successful!") + return saveToken(configPath, contextName, serverURL, token, setDefault) + } + + // Check if we should continue polling + if oauth2Err, ok := err.(*oauth2Error); ok { + switch oauth2Err.ErrorCode { + case "authorization_pending": + continue + case "slow_down": + pollInterval *= 2 + continue + case "access_denied": + return fmt.Errorf("authorization was denied") + case "expired_token": + return fmt.Errorf("authorization code expired") + default: + return fmt.Errorf("OAuth2 error: %s - %s", oauth2Err.ErrorCode, oauth2Err.ErrorDescription) + } + } + + return fmt.Errorf("failed to get token: %w", err) + } + + return fmt.Errorf("authorization timed out") +} + +func pollForToken(serverURL, deviceCode string) (string, error) { + tokenReq := models.DeviceTokenRequest{ + DeviceCode: deviceCode, + GrantType: "urn:ietf:params:oauth:grant-type:device_code", + } + + jsonData, err := json.Marshal(tokenReq) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := http.Post(serverURL+"/oauth2/device/token", "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to request token: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == 200 { + var tokenResp models.DeviceTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("failed to decode token response: %w", err) + } + return tokenResp.AccessToken, nil + } + + var errResp oauth2Error + if err := json.Unmarshal(body, &errResp); err != nil { + return "", fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + return "", &errResp +} From 7bf760802ddcfa51542034be52474428390f6986 Mon Sep 17 00:00:00 2001 From: Andy Doan Date: Thu, 18 Jun 2026 16:27:13 -0500 Subject: [PATCH 5/5] fix: Pass version to auth handler templates Signed-off-by: Andy Doan --- auth/provider_local.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auth/provider_local.go b/auth/provider_local.go index 436537d7..1a207e42 100644 --- a/auth/provider_local.go +++ b/auth/provider_local.go @@ -18,6 +18,7 @@ import ( "github.com/foundriesio/update-server/server/ui/web/templates" "github.com/foundriesio/update-server/storage" "github.com/foundriesio/update-server/storage/users" + "github.com/foundriesio/update-server/version" ) const localLoginTemplate = "local-login.html" @@ -198,10 +199,12 @@ func (p localProvider) renderLoginPage(c echo.Context, reason string) error { User *users.User NavItems []string CsrfToken string + Version string }{ Title: "Login", Reason: reason, CsrfToken: csrfToken, + Version: version.Version, } return templates.Templates.ExecuteTemplate(c.Response(), localLoginTemplate, context) } @@ -246,11 +249,13 @@ func (p *localProvider) handlePasswordPage(c echo.Context, session *Session) err User *users.User NavItems []string CsrfToken string + Version string }{ Title: "Change Password", Message: "Your password has expired. Please choose a new password.", User: session.User, CsrfToken: csrfToken, + Version: version.Version, } return templates.Templates.ExecuteTemplate(c.Response(), localPasswordChangeTemplate, context) }