SSO, MFA, and device trust implementation plan (Phase A)
SSO, MFA, and device trust implementation plan (Phase A)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship enterprise identity for the management console: OIDC single sign-on, TOTP multi-factor authentication with recovery codes, and revocable trusted devices, all audited and observable.
Architecture: Add a database-authoritative identity layer beside the existing local-password login. internal/auth/oidc is a self-contained relying party with no database or handler knowledge. internal/auth services (LoginService, MFAService, DeviceService, OIDCProviderService, AuthPolicyService) orchestrate repositories and emit audit rows. internal/server gains thin handlers only. Short-lived cross-node state (login MFA challenges, OIDC state, login tickets) lives in an encrypted auth_challenges table so any cluster node can complete a flow started on another node.
Tech Stack: Go 1.26 standard library (crypto/hmac, crypto/sha1, crypto/rsa, crypto/ecdsa, crypto/ed25519, encoding/base32, encoding/jose-free JWK parsing), existing golang.org/x/crypto/argon2, existing SQLite/MySQL storage layer, existing internal/auth.SecretStore AES-256-GCM, Vue 3 + TypeScript + Element Plus console, Vitest.
Spec: docs/superpowers/specs/2026-09-18-sso-mfa-device-trust-design.md
Roadmap: docs/superpowers/specs/2026-09-18-enterprise-capability-roadmap-design.md
Global Constraints
- No new third-party Go dependency. OIDC, JWKS, and TOTP are implemented with the standard library because signature and claim validation must be explicit, testable, and free of transitive supply chain growth.
alg=noneand everyHS*algorithm are rejected unconditionally inid_tokenverification. There is no configuration that enables them.- No plaintext secret is persisted, logged, audited, exported, or returned by an API. OIDC
client_secret, TOTP shared secrets, and PKCE verifiers are AES-GCM ciphertext produced byinternal/auth.SecretStore. WhenTUNNELMESH_TOKEN_ENCRYPTION_KEYis absent, enrollment and provider creation fail closed with503anddata.error = "secret_storage_unavailable". - Recovery codes, device tokens, and login attempt bucket keys are stored only as one-way hashes.
POST /api/v1/auth/loginkeeps returning{token, user}when MFA is not triggered.auth_settings.mfa_modedefaults todisabled, so an upgraded deployment behaves exactly as before until an operator opts in.- Schema v14 is additive only.
migrations/ddl.sql,migrations/incremental/v0013_to_v0014/{mysql,sqlite}.sql,storage.SchemaVersion, migration tests, anddocs/operations/schema-upgrades.mdall change in the same task. - Released incremental scripts are immutable.
- Audit
detailsnever contain passwords, hashes, TOTP codes, recovery codes, device tokens, tickets,code_verifier,client_secret, or fullAuthorizationheaders. - All authorization is evaluated from the bearer principal.
userId,role, andproviderIdvalues from a request body are never trusted as identity. - Do not add
go test,go test -race, orgo vetto CI; the user explicitly rejected that change. - Do not commit, push, merge, or open a remote PR without explicit user authorization.
- Do not include
Co-Authored-By,Generated-by, or any AI attribution trailer in commit messages. web/src/i18n/messages/zh-CN.tsdefinesMessageSchema; add keys there first, then mirror them inen-US.tsor the frontend test suite fails.- Run the full local validation set even though CI stays lightweight.
Task 1: Schema v14 migration and identity models
Files:
- Modify:
migrations/ddl.sql - Create:
migrations/incremental/v0013_to_v0014/mysql.sql - Create:
migrations/incremental/v0013_to_v0014/sqlite.sql - Modify:
internal/storage/db.go - Modify:
internal/storage/models.go - Modify:
internal/storage/db_test.go - Modify:
docs/operations/schema-upgrades.md
Interfaces:
- Produces:
storage.SchemaVersion == 14. - Produces: model structs
AuthSettings,OIDCProvider,UserIdentity,UserMFA,UserRecoveryCode,UserDevice,AuthChallenge,AuthLoginAttemptininternal/storage/models.go. - Produces:
User.AuthSource stringandUser.MFARequired boolfields. - Produces: constants
AuthSourceLocal,AuthSourceOIDC,AuthSourceMixed,MFAModeDisabled,MFAModeOptional,MFAModeRequired,MFAStatusPending,MFAStatusEnabled,ChallengeKindLoginMFA,ChallengeKindOIDCState,ChallengeKindLoginTicket,PasswordHashNone = "*". - Consumes: nothing; this task is the foundation for Tasks 2-10.
Steps:
- Write the failing test first in
internal/storage/db_test.go:TestSchemaVersionIsFourteenassertsSchemaVersion == 14.TestMigrateV13ToV14AddsIdentityTablesopens a SQLite file, forcesschema_meta.version = 13with the v13 table set, runs the migration path, and asserts every new table exists andusershasauth_sourceandmfa_requiredcolumns with the documented defaults.TestMigrateV13ToV14IsIdempotentruns the migration twice and asserts the second run reports no change and no error.TestFreshDatabaseMatchesIncrementalChainbuilds one database frommigrations/ddl.sqland another by replaying v0005→v0014 incremental scripts, then asserts both expose the same table set and, for the identity tables, the same column names.
- Run
go test ./internal/storage -run 'SchemaVersion|V13ToV14|IncrementalChain' -count=1and confirm it fails withSchemaVersion == 13and missing-table errors. Record the failing output. - Add the eight DDL blocks from spec section 3 to
migrations/ddl.sql, keeping the existing file’sCREATE TABLE IF NOT EXISTSstyle,VARBINARY(255)identifier convention, and index naming (idx_<table>_<cols>). Add the twouserscolumns to theusersblock inddl.sqlasauth_source VARCHAR(32) NOT NULL DEFAULT 'local'andmfa_required INTEGER NOT NULL DEFAULT 0. - Write
migrations/incremental/v0013_to_v0014/sqlite.sqlwithALTER TABLE users ADD COLUMN ...statements and the eightCREATE TABLE IF NOT EXISTSblocks plus indexes. - Write
migrations/incremental/v0013_to_v0014/mysql.sqlwith the same intent using MySQL types (VARBINARY(255),VARCHAR(n),INTEGER,TEXT,ENGINE=InnoDB DEFAULT CHARSET=utf8mb4).UNIQUE(provider_id, subject)onuser_identitiesandUNIQUE(code_hash)onuser_recovery_codesmust be declared inline so both drivers agree. - Bump
SchemaVersionto14ininternal/storage/db.go. - Add the model structs and constants to
internal/storage/models.go. Every struct carries only the columns it owns; no secret field is added to a struct that is rendered by an API handler. - Add the v13 → v14 upgrade and rollback section to
docs/operations/schema-upgrades.md, stating that the change is additive, that a binary downgrade requires a backup restore becauseSchemaVersionvalidation rejects a newer database, and that feature-level rollback ismfa_mode='disabled'plus disabling all providers. - Run
go test ./internal/storage -count=1and confirm green.
Verification:
go test ./internal/storage -count=1
go vet ./internal/storage ./migrations
git diff --check
Expected: all tests pass; git diff --check prints nothing.
Rollback: Delete the new incremental directory, revert ddl.sql, models.go, and SchemaVersion. A database already migrated to v14 must be restored from backup before running a v13 binary.
Task 2: Identity repositories
Files:
- Create:
internal/storage/identity_repository.go - Create:
internal/storage/identity_repository_test.go - Modify:
internal/storage/db.go - Modify:
internal/storage/storage_contract_test.go
Interfaces:
- Produces:
AuthSettingsRepository { Get(ctx) (AuthSettings, error); Update(ctx, AuthSettings) error; SeedIfMissing(ctx, AuthSettings) (AuthSettings, error) }. - Produces:
OIDCProviderRepository { Create; Get(ctx,id); GetByName(ctx,name); List(ctx,cursor,limit) (Page[OIDCProvider], error); Update; Delete; ListEnabledPublic(ctx) ([]OIDCProvider, error); CountEnabled(ctx) (int, error) }. - Produces:
UserIdentityRepository { Create; GetByProviderSubject(ctx,providerID,subject); ListByUser(ctx,userID); UpdateLastLogin(ctx,id,time.Time); Delete(ctx,id); DeleteByUser(ctx,userID) }. - Produces:
UserMFARepository { Upsert(ctx,UserMFA); Get(ctx,userID); SetStatus(ctx,userID,status,time.Time); MarkUsed(ctx,userID string, step int64, when time.Time) error; Delete(ctx,userID) }. - Produces:
UserRecoveryCodeRepository { ReplaceAll(ctx,userID string, hashes [][]byte) error; ListUnused(ctx,userID) ([][]byte, error); Consume(ctx,userID string, hash []byte) (bool, error) }. - Produces:
UserDeviceRepository { Create; GetByTokenHash(ctx,hash) (UserDevice, error); ListByUser(ctx,userID); Touch(ctx,id,time.Time); Revoke(ctx,id); RevokeAllForUser(ctx,userID); CountActive(ctx,userID) (int, error); OldestActive(ctx,userID) (UserDevice, error); DeleteExpired(ctx, time.Time) (int64, error) }. - Produces:
AuthChallengeRepository { Create; Get(ctx,id) (AuthChallenge, error); Consume(ctx,id) (bool, error); IncrementAttempts(ctx,id) (attempts int, consumed bool, err error); DeleteExpired(ctx, time.Time) (int64, error); CountPending(ctx) (int, error) }. - Produces:
AuthLoginAttemptRepository { RegisterFailure(ctx,bucketKey string, window time.Duration, maxAttempts int, block time.Duration) (blocked bool, retryAfter time.Duration, err error); IsBlocked(ctx,bucketKey string) (bool, time.Duration, error); RegisterSuccess(ctx,bucketKey string) error; CountBlocked(ctx) (int, error) }. - Produces:
DBaccessorsAuthSettings(),OIDCProviders(),UserIdentities(),UserMFA(),UserRecoveryCodes(),UserDevices(),AuthChallenges(),AuthLoginAttempts(), plusIdentityTransaction(ctx, func(IdentityRepositories) error) errorwhereIdentityRepositoriesbundles users, MFA, recovery codes, devices, challenges, and audits for atomic login and enrollment writes. - Consumes: Task 1 models.
Steps:
- Write
internal/storage/identity_repository_test.gofirst with a SQLite temp database created through the existing test helper used bycredential_repository_test.go. Cover, per repository:- round-trip create/get/update/delete,
- cursor pagination ordering and
hasMoreforOIDCProviderRepository.List, - the
UNIQUE(provider_id, subject)constraint returning a typedErrConflict-style error, Consumereturningfalseon the second call,IncrementAttemptsreturning monotonically increasing counts andconsumed=trueonce the row is consumed,RegisterFailurereturningblocked=trueand a positiveretryAfteronly aftermaxAttemptsinside the window, andfalseafter the window rolls over,MarkUsedrefusing to movelast_used_stepbackwards,DeleteExpiredremoving only rows past the cutoff,IdentityTransactionrolling back every write when the callback returns an error.
- Add the same repositories to
internal/storage/storage_contract_test.goso the existing MySQL contract harness exercises them whenTUNNELMESH_MYSQL_TEST_DSNis set. - Run
go test ./internal/storage -run Identity -count=1and confirm it fails to compile against the missing types. Record the output. - Implement
internal/storage/identity_repository.gofollowing the existingcredential_repository.goconventions: constructor functions returning interfaces,sql.ErrNoRowsmapped to the package’s not-found sentinel, timestamps encoded with the same helper the rest of the file uses, and noSELECT *. - Wire the repositories and accessors into the
DBstruct ininternal/storage/db.go, constructing them in the same place the existing repositories are constructed so both drivers get identical wiring. - Run
go test ./internal/storage -count=1and confirm green.
Verification:
go test ./internal/storage -count=1
go test -race ./internal/storage
go vet ./internal/storage
git diff --check
Expected: all pass.
Rollback: Delete identity_repository.go and its test, revert the DB wiring. The v14 tables remain harmless.
Task 3: TOTP and recovery code primitives
Files:
- Create:
internal/auth/totp.go - Create:
internal/auth/totp_test.go - Create:
internal/auth/recovery_code.go - Create:
internal/auth/recovery_code_test.go
Interfaces:
- Produces:
type TOTPParams struct { Digits int; Period time.Duration; Skew int }. - Produces:
GenerateTOTPSecret() (string, error)returning a base32 160-bit secret. - Produces:
TOTPCode(secret string, at time.Time, p TOTPParams) (string, error). - Produces:
VerifyTOTP(secret, code string, at time.Time, p TOTPParams) (step int64, ok bool, err error)wherestepis the accepted counter value used for the replay guard. - Produces:
OTPAuthURL(issuer, account, secret string, p TOTPParams) (string, error). - Produces:
GenerateRecoveryCodes(count int) (codes []string, hashes [][]byte, err error). - Produces:
HashRecoveryCode(code string) []byteandRecoveryCodeMatches(hash, candidate []byte) bool. - Produces:
IsRecoveryCode(candidate string) booldetecting thetmrc-prefix. - Consumes: nothing.
Steps:
- Write
internal/auth/totp_test.gofirst:- RFC 6238 Appendix B SHA-1 vectors for T = 59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000 with the standard ASCII secret
12345678901234567890, asserting 6-digit and 8-digit outputs. Skew=1accepts the previous and next step and rejects two steps away.VerifyTOTPreturns the accepted step so callers can enforce monotonicity.- Non-numeric, short, long, and empty codes return
ok=falsewith no error. - A secret that is not valid base32 returns an error, not a panic.
OTPAuthURLproducesotpauth://totp/<issuer>:<account>?secret=…&issuer=…&algorithm=SHA1&digits=…&period=…with percent-encoded label and issuer.
- RFC 6238 Appendix B SHA-1 vectors for T = 59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000 with the standard ASCII secret
- Write
internal/auth/recovery_code_test.gofirst:- generated codes are unique, match
^tmrc-[A-Z2-7]{20}$, and hashes are 32 bytes, RecoveryCodeMatchesis case-insensitive on the base32 body and ignores surrounding whitespace,- a wrong code never matches,
IsRecoveryCodeaccepts the prefix form and rejects a 6-digit TOTP code.
- generated codes are unique, match
- Run
go test ./internal/auth -run 'TOTP|Recovery' -count=1and confirm a compile failure. Record the output. - Implement
internal/auth/totp.gowithcrypto/hmac,crypto/sha1,encoding/base32, andmath/big-free big-endian counter encoding. Dynamic truncation follows RFC 4226 section 5.3. Comparison useshmac.Equalon the digit strings encoded as bytes. - Implement
internal/auth/recovery_code.gowithcrypto/randandcrypto/sha256, and a constant-time comparison viahmac.Equal. - Add a doc comment on each exported function stating the RFC it implements and why SHA-1 is retained (authenticator app compatibility, not a security downgrade of the shared secret).
- Run
go test ./internal/auth -count=1and confirm green.
Verification:
go test ./internal/auth -run 'TOTP|Recovery' -count=1
go test ./internal/auth -count=1
go vet ./internal/auth
Expected: RFC 6238 vectors match exactly; all tests pass.
Rollback: Delete the four files. Nothing else depends on them until Task 4.
Task 4: MFA service
Files:
- Create:
internal/auth/mfa_service.go - Create:
internal/auth/mfa_service_test.go - Modify:
internal/auth/service.go - Create:
internal/auth/errors.go - Modify:
internal/auth/errors_test.go(create if absent)
Interfaces:
- Produces:
type MFAService structconstructed byNewMFAService(repos MFARepositories, secrets SecretProvider, cfg MFAConfig, audits AuditWriter) *MFAService. - Produces:
MFARepositoriesinterface bundlingstorage.UserMFARepository,storage.UserRecoveryCodeRepository,storage.UserDeviceRepository, andstorage.UserRepository. - Produces:
SecretProvider interface { Encrypt(string) (ciphertext, nonce []byte, keyID string, version int, err error); Decrypt(ciphertext, nonce []byte, keyID string, version int) (string, error); Available() bool }with an adapter over*SecretStore. This is the seam that keeps the service testable without a real key and letsinternal/serverreuse it for credentials. - Produces:
MFAStatus struct { Status string; EnabledAt *time.Time; LastUsedAt *time.Time; RemainingRecoveryCodes int; Policy AuthPolicy }. - Produces:
Enrollment struct { Secret string; OTPAuthURL string; RecoveryCodes []string; ExpiresAt time.Time }. - Produces: methods
Status(ctx,userID),Enroll(ctx,userID,currentPassword string),Enable(ctx,userID,code string),Disable(ctx,userID,code string),AdminReset(ctx,actorID,userID string),VerifyCode(ctx,userID,code string, at time.Time) (VerifyResult, error). - Produces:
VerifyResult struct { Method string; RecoveryCodesExhausted bool }. - Produces: sentinel errors
ErrSecretStorageUnavailable,ErrMFANotEnrolled,ErrMFAPendingNotConfirmed,ErrMFACodeInvalid,ErrMFAReplayDetected,ErrMFARequiredByPolicy,ErrCurrentPasswordRequired,ErrLastAdminProtectedininternal/auth/errors.go. - Consumes: Task 2 repositories, Task 3 primitives.
Steps:
- Write
internal/auth/mfa_service_test.gofirst with in-memory fakes for the four repositories and a fakeSecretProvider:Enrollreturns a decodable base32 secret, a well-formedotpauth://URL, and exactlycfg.RecoveryCodescodes; the stored row hasstatus=pending, ciphertext non-empty, and no plaintext secret anywhere in the fake store.Enrolltwice replaces the pending row and invalidates the first secret.Enablewith a code generated from the returned secret flipsstatustoenabledand writes anauth.mfa.enableaudit row whosedetailscontain no code and no secret.Enablewith a wrong code returnsErrMFACodeInvalidand leavesstatus=pending.VerifyCodewith a TOTP code returnsMethod="totp"; a second call with the same code in the same step returnsErrMFAReplayDetected.VerifyCodewith a recovery code returnsMethod="recovery", marks it used, and decrements the remaining count; when the last code is consumed,RecoveryCodesExhaustedis true.VerifyCodewhenstatus=pendingreturnsErrMFAPendingNotConfirmed.- Every method returns
ErrSecretStorageUnavailablewhenSecretProvider.Available()is false, andEnrollpersists nothing in that case. DisablewithPolicy.Mode=requiredreturnsErrMFARequiredByPolicy; withMode=optionalit deletes the secret, deletes all recovery codes, revokes all trusted devices, and writesauth.mfa.disable.Enrollfor a user withauth_source=localrequirescurrentPassword; a wrong password returnsErrCurrentPasswordRequired. A user withauth_source=oidcandpassword_hash="*"skips the password check.AdminResetclears MFA, clears recovery codes, revokes devices, and writesauth.mfa.resetwith the actor id.AdminResetandDisablerefuse to act on the last enabled administrator and returnErrLastAdminProtected.
- Run
go test ./internal/auth -run MFA -count=1and confirm a compile failure. Record the output. - Create
internal/auth/errors.gocollecting the sentinels above plus the existingErrInvalidCredentials,ErrUnauthenticated,ErrForbiddenre-exports so handlers have one import site. Keepservice.godeclarations as the definitions and haveerrors.goonly add new ones, to avoid a duplicate-declaration break. - Implement
internal/auth/mfa_service.go. Encryption is the first operation inEnroll; if it fails, nothing is written.VerifyCodereads the row, decrypts, verifies, and only then callsMarkUsedwith the accepted step so a failed verification never advances the replay guard. - Add
AuthPolicyresolution as a pure functionResolvePolicy(settings storage.AuthSettings, user storage.User) AuthPolicyininternal/auth/service.go, returning{Mode string; Required bool; DeviceTrustEnabled bool; AllowBypass bool; SessionTokenTTL time.Duration}.Requiredis true whenuser.MFARequiredis true, orMode == required, or (Mode == optionaland the user has enabled MFA). - Run
go test ./internal/auth -count=1and confirm green.
Verification:
go test ./internal/auth -count=1
go test -race ./internal/auth
go vet ./internal/auth
Expected: all pass, including the replay and fail-closed cases.
Rollback: Delete mfa_service.go, errors.go, and their tests; revert the ResolvePolicy addition. No schema or handler change exists yet.
Task 5: Device trust service
Files:
- Create:
internal/auth/device_service.go - Create:
internal/auth/device_service_test.go - Modify:
internal/auth/service.go
Interfaces:
- Produces:
NewDeviceService(devices storage.UserDeviceRepository, cfg DeviceTrustConfig, audits AuditWriter) *DeviceService. - Produces:
DeviceTrustConfig struct { Enabled bool; TTL time.Duration; MaxPerUser int; CookieName string; CookieSecure bool; CookieSameSite string }. - Produces:
Issue(ctx, userID, userAgent, ip string) (token string, device storage.UserDevice, err error). - Produces:
Validate(ctx, token string) (storage.UserDevice, bool, error)where the boolean reports trust and the device row is returned forlast_seen_atrefresh. - Produces:
List(ctx, userID string, currentToken string) ([]DeviceView, error)whereDeviceViewexposesid,name,userAgent,ip,trustedAt,expiresAt,lastSeenAt,current, and never the hash. - Produces:
Revoke(ctx, actorID, userID, deviceID string) errorandRevokeAll(ctx, actorID, userID string) error. - Produces:
Cookie(token string, cfg DeviceTrustConfig) *http.CookieandTokenFromRequest(r *http.Request, cfg DeviceTrustConfig) string. - Consumes: Task 2 repositories.
Steps:
- Write
internal/auth/device_service_test.gofirst:Issuereturns a 43-character base64url token; the stored row holds only the 32-byte SHA-256 hash and no substring of the token.Validatesucceeds for a fresh token, fails for an expired token, fails for a revoked token, and fails for an unknown token without hitting the database more than once.IssuebeyondMaxPerUserrevokes the row with the oldestlast_seen_atand keeps the count at the cap.Listmarks exactly one entrycurrentwhencurrentTokenmatches, and returns entries ordered bylastSeenAtdescending.Revokeby a non-owner returnsErrForbidden; an admin actor succeeds for any user.CookiesetsHttpOnly,Path=/,SameSiteper config, andSecurewhen configured;CookieSameSite=nonewithoutSecurereturns a configuration error rather than building an insecure cookie.IssuewithEnabled=falsereturns a typedErrDeviceTrustDisabledand writes nothing.- Audit rows are written for issue and revoke, and their
detailscontain no token or hash.
- Run
go test ./internal/auth -run Device -count=1and confirm a compile failure. Record the output. - Implement
internal/auth/device_service.go. Cap eviction happens insidestorage.IdentityTransactionso a concurrent issue cannot exceed the cap. - Run
go test ./internal/auth -count=1and confirm green.
Verification:
go test ./internal/auth -run Device -count=1
go test ./internal/auth -count=1
go vet ./internal/auth
Expected: all pass.
Rollback: Delete the two files and revert the service.go import if any.
Task 6: Login service, challenge store, and throttle
Files:
- Create:
internal/auth/login_service.go - Create:
internal/auth/login_service_test.go - Create:
internal/auth/challenge.go - Create:
internal/auth/challenge_test.go - Modify:
internal/auth/service.go - Modify:
internal/storage/models.go
Interfaces:
- Produces:
ChallengeStorewrappingstorage.AuthChallengeRepositoryandSecretProviderwithCreate(ctx, kind, userID string, payload any, ttl time.Duration, maxAttempts int) (id string, err error),Load(ctx, id, kind string) (Challenge, error),Consume(ctx, id string) (bool, error),Fail(ctx, id string) (attempts int, exhausted bool, err error).Challenge.Payloadis amap[string]stringdecoded from decrypted JSON. - Produces:
LoginServiceconstructed byNewLoginService(deps LoginDependencies, cfg LoginConfig, audits AuditWriter) *LoginServicewhereLoginDependenciesbundles users, tokens, challenges, attempts, MFA, and devices. - Produces:
LoginRequest struct { Username, Password, ClientIP, UserAgent string; TrustDevice bool; DeviceToken string }. - Produces:
LoginOutcome struct { State string; Token string; User storage.User; ChallengeID string; Methods []string; ExpiresAt time.Time; DeviceTrusted bool; DeviceToken string; RetryAfter time.Duration }withStateone ofauthenticated,mfa_required,throttled,invalid. - Produces:
Login(ctx, LoginRequest) (LoginOutcome, error). - Produces:
VerifyMFA(ctx, challengeID, code, clientIP, userAgent string, trustDevice bool) (LoginOutcome, error). - Produces:
IssueForAuthenticatedUser(ctx, user storage.User, method, clientIP, userAgent string, trustDevice bool, deviceToken string) (LoginOutcome, error)used by the OIDC callback. - Produces: sentinel errors
ErrLoginThrottled,ErrChallengeInvalid,ErrChallengeExhausted. - Consumes: Tasks 2-5.
Steps:
- Write
internal/auth/challenge_test.gofirst:CreatethenLoadround-trips the payload through encryption; the stored ciphertext does not contain any plaintext payload value.Loadwith the wrongkindreturnsErrChallengeInvalid.Loadafter expiry returnsErrChallengeInvalidand does not leak the expiry reason.Consumereturns true once and false afterwards.Failincrements and reportsexhausted=truewhenattemptsreachesmax_attempts, and marks the challenge consumed at that point.Createwith an unavailableSecretProviderreturnsErrSecretStorageUnavailableand writes nothing.
- Write
internal/auth/login_service_test.gofirst, covering each branch of spec section 5.1 and 5.2:- correct password, MFA disabled →
State=authenticated, a non-emptyToken, and anapi_tokensrow whose hash validates through the existingAuthService.ValidateToken. - unknown username, disabled user, deleted user, and wrong password all →
State=invalidwith the same error and the same audit action. - throttle: after
MaxAttemptsfailures in the window,State=throttledwith a positiveRetryAfter; a success resets the bucket. users.mfa_required=1withmfa_mode=disabled→State=mfa_requiredand no token issued.mfa_mode=requiredfor a user with no enrollment →State=mfa_requiredwithMethods=["totp"]and the challenge present; verifying then returnsErrMFANotEnrolledsurfaced asState=invalidso the UI can point at enrollment.mfa_mode=optionalwithstatus=enabled→State=mfa_requiredwithMethods=["totp","recovery"].- trusted device cookie present and
AllowBypass=true→State=authenticated,DeviceTrusted=true. - trusted device cookie present and
AllowBypass=false→State=mfa_required. TrustDevice=trueon a successful MFA verification issues a device token, setsDeviceTrusted=true, and returns the token exactly once.VerifyMFAwith a wrong code increments attempts and returnsErrMFACodeInvalid; afterMaxAttemptsit returnsErrChallengeExhaustedand the challenge is unusable.VerifyMFAwith a consumed challenge returnsErrChallengeInvalid.session_token_ttl > 0produces anapi_tokensrow withexpires_atset;0leaves it nil, matching today’s behaviour.- No audit
detailsmap contains a key whose value equals the password, the code, the token, or the device token; assert this by scanning the recorded details for those substrings.
- correct password, MFA disabled →
- Run
go test ./internal/auth -run 'Challenge|Login' -count=1and confirm a compile failure. Record the output. - Implement
internal/auth/challenge.goandinternal/auth/login_service.go.Loginmust not hold the plaintext password beyond the verification call, and must build the challenge payload without the password. - Keep
AuthService.Loginintact as the compatibility path and haveLoginServicebe the new orchestrator;AuthServicegains no new responsibility. - Run
go test ./internal/auth -count=1and confirm green.
Verification:
go test ./internal/auth -count=1
go test -race ./internal/auth
go vet ./internal/auth
Expected: all pass, including the audit-leak assertions.
Rollback: Delete the four files and revert Login. POST /auth/login still uses AuthService.Login until Task 9 flips the handler.
Task 7: OIDC relying party
Files:
- Create:
internal/auth/oidc/provider.go - Create:
internal/auth/oidc/discovery.go - Create:
internal/auth/oidc/jwks.go - Create:
internal/auth/oidc/token.go - Create:
internal/auth/oidc/idtoken.go - Create:
internal/auth/oidc/claims.go - Create:
internal/auth/oidc/oidc_test.go - Create:
internal/auth/oidc/discovery_test.go - Create:
internal/auth/oidc/jwks_test.go - Create:
internal/auth/oidc/idtoken_test.go - Create:
internal/auth/oidc/claims_test.go
Interfaces:
- Produces:
type Provider structholding the non-secret provider configuration plusSecret func(ctx) (string, error)so the secret is fetched lazily and never copied into logs. - Produces:
type RelyingParty structconstructed byNewRelyingParty(httpClient *http.Client, cfg Config, secrets SecretProvider) *RelyingParty. - Produces:
Config struct { HTTPTimeout time.Duration; StateTTL time.Duration; LoginTicketTTL time.Duration; JWKSCacheTTL time.Duration; MaxBodyBytes int64; ClockSkew time.Duration }. - Produces:
Discover(ctx, p Provider) (Endpoints, error)withEndpoints{Authorization, Token, Userinfo, JWKS, Issuer}; explicit provider overrides win over discovery. - Produces:
AuthRequest struct { State, Nonce, CodeChallenge, RedirectURI, Scope []string }andBuildAuthURL(ctx, p Provider, req AuthRequest) (string, error). - Produces:
NewPKCE() (verifier, challenge string, err error)using S256. - Produces:
Exchange(ctx, p Provider, code, verifier, redirectURI string) (TokenResponse, error)withTokenResponse{IDToken, AccessToken, TokenType, ExpiresIn}. - Produces:
VerifyIDToken(ctx, p Provider, rawIDToken, expectedNonce, accessToken string) (Claims, error). - Produces:
Claims struct { Subject, Username, Email string; EmailVerified bool; Raw map[string]any }andResolveIdentity(p Provider, c Claims) (username, role string, err error). - Produces: sentinel errors
ErrDiscoveryFailed,ErrJWKSFetchFailed,ErrTokenExchangeFailed,ErrIDTokenInvalid,ErrUnsupportedAlgorithm,ErrNoMatchingKey,ErrClaimMissing,ErrRoleMappingInvalid. - Consumes: nothing outside the standard library.
Steps:
- Write
internal/auth/oidc/idtoken_test.gofirst using a locally generated RSA and ECDSA key served by anhttptestJWKS endpoint:- a valid RS256 token with correct
iss,aud,exp,nbf,nonce, andat_hashverifies and returns claims. - ES256, ES384, ES512, PS256, and EdDSA verify when listed in
id_token_algs. alg=nonereturnsErrUnsupportedAlgorithmeven whennoneis in the allowlist, because the allowlist parser rejects it at configuration time and verification rejects it again.HS256signed with the client secret returnsErrUnsupportedAlgorithm.- an
algnot in the provider allowlist returnsErrUnsupportedAlgorithm. - wrong
iss, wrongaud, missingaud, expiredexp, futurenbfbeyond the 60s skew, wrongnonce, and wrongat_hasheach returnErrIDTokenInvalid. - a signature from a different key with a matching
kidreturnsErrIDTokenInvalid. - an unknown
kidtriggers exactly one JWKS refresh and then fails withErrNoMatchingKeyif still absent. - a malformed header, malformed payload, wrong segment count, or non-base64url segment returns
ErrIDTokenInvalidwithout panicking.
- a valid RS256 token with correct
- Write
internal/auth/oidc/discovery_test.goandinternal/auth/oidc/jwks_test.gofirst:- discovery parses
authorization_endpoint,token_endpoint,jwks_uri,userinfo_endpoint,issuer; a missingjwks_uriwhen no override is configured returnsErrDiscoveryFailed. - a non-200 status, a body larger than
MaxBodyBytes, invalid JSON, and a client timeout each returnErrDiscoveryFailed. - an
http(non-TLS) issuer URL returnsErrDiscoveryFailed. - JWKS caching serves the second request from cache within
JWKSCacheTTL, andhttptestrequest counting asserts exactly one upstream fetch. - JWKS parsing accepts
RSA(n,e),EC(crv,x,y), andOKP(x) keys, and skips unknownktywithout error.
- discovery parses
- Write
internal/auth/oidc/claims_test.gofirst:username_claim=preferred_usernamewins; an empty value falls back toemail; both empty falls back to<provider>-<sub prefix>and is a valid username under the existing 3-64 character rule.- role mappings are evaluated in order and the first match wins; a mapping whose claim is a string array matches on membership.
- an unknown claim name in a mapping is skipped, not fatal.
- a mapping with
roleother thanadmin/userreturnsErrRoleMappingInvalid. - no mapping matched yields
default_role.
- Write
internal/auth/oidc/oidc_test.gofirst for the end-to-end happy path and the token exchange failures:BuildAuthURLincludesresponse_type=code,scopewithopenidfirst,state,nonce,code_challenge,code_challenge_method=S256, andredirect_uri;code_challengeis the base64url SHA-256 of the verifier.Exchangepostsapplication/x-www-form-urlencodedwithgrant_type=authorization_code, and sends basic auth only when a secret exists.- a
4xx/5xxtoken response returnsErrTokenExchangeFailedand the error string does not contain the client secret or the code. - an OAuth
errorresponse body is parsed and surfaced asErrTokenExchangeFailed.
- Run
go test ./internal/auth/oidc -count=1and confirm a compile failure. Record the output. - Implement the six source files. Every outbound HTTP call uses the injected
*http.ClientwithTimeoutset, and every response body is read throughio.LimitReaderbounded byMaxBodyBytes. - Implement JWK verification with
crypto/rsa.VerifyPKCS1v15/rsa.VerifyPSS,ecdsa.VerifyASN1-compatible rawr||shandling for JWS, anded25519.Verify. Base64url decoding usesencoding/base64.RawURLEncoding. - Add a package doc comment stating the accepted algorithm set and the unconditional rejection of
noneandHS*. - Run
go test ./internal/auth/oidc -count=1and confirm green.
Verification:
go test ./internal/auth/oidc -count=1
go test -race ./internal/auth/oidc
go vet ./internal/auth/oidc
Expected: all pass. No new entry appears in go.mod.
Rollback: Delete the internal/auth/oidc directory. Nothing else imports it until Task 8.
Task 8: OIDC provider service and SSO admin API
Files:
- Create:
internal/auth/oidc_provider_service.go - Create:
internal/auth/oidc_provider_service_test.go - Create:
internal/auth/auth_policy_service.go - Create:
internal/auth/auth_policy_service_test.go - Create:
internal/server/sso_api.go - Create:
internal/server/sso_api_test.go - Modify:
internal/server/api.go - Modify:
internal/server/credential_service.go
Interfaces:
- Produces:
OIDCProviderServicewithCreate(ctx, actorID string, in ProviderInput, idempotencyKey string),Update,Delete,Get,List(ctx, cursor string, limit int),ListForLogin(ctx),Test(ctx, actorID, id string) (TestReport, error),ResolveRelyingParty(ctx, name string) (oidc.Provider, error). - Produces:
ProviderViewwithid,name,displayName,issuer,clientId,scopes[],redirectUri,idTokenAlgs[],usernameClaim,roleMappings[],defaultRole,autoCreateUsers,publicListed,enabled,hasSecret,createdAt,updatedAt. It never carries a secret field. - Produces:
TestReport struct { DiscoveryOK, JWKSOK bool; Algorithms []string; Endpoints map[string]string; Error string }. - Produces:
AuthPolicyServicewithGet(ctx),Update(ctx, actorID string, in AuthPolicyInput, idempotencyKey string), seeded fromconfig.SecurityConfig.Authon first read. - Produces: HTTP routes under
/api/v1/sso/providers*and/api/v1/auth/policy. - Consumes: Tasks 2, 3, 7.
Steps:
- Write
internal/auth/oidc_provider_service_test.gofirst:Createvalidatesnameas a URL-safe slug (^[a-z0-9][a-z0-9-]{1,62}$),issueras an absolutehttpsURL,redirectUrias an absolute URL on an allowed base,scopescontainingopenid,idTokenAlgsdrawn from the accepted set,defaultRolein{admin,user}, and eachroleMappingsentry throughoidc.ResolveIdentityvalidation. Each invalid case returns a distinct validation error and persists nothing.Createwith a secret stores ciphertext and reportshasSecret=true;ProviderViewnever contains the secret or the ciphertext.CreatewithoutTUNNELMESH_TOKEN_ENCRYPTION_KEYreturnsErrSecretStorageUnavailable.Updatewith an empty secret keeps the existing ciphertext; with a new secret it re-encrypts and updatesclient_secret_key_id.Updatechangingnameis rejected, because the name is part of the public callback URL.Deleterefuses to delete the last enabled provider when any user hasauth_source=oidcand no local password, returningErrLastAdminProtected-styleErrProviderInUse, so SSO-only accounts are not orphaned.Testperforms discovery and JWKS retrieval against anhttptestIdP and returns the report; a failing IdP returnsDiscoveryOK=falsewith a genericErrorthat contains no secret.Create,Update,Delete, andTesteach write one audit row;detailscontain the provider id and changed field names only, never values forclient_secret.CreateandUpdatereplay the same response for a repeatedIdempotency-Key.
- Write
internal/auth/auth_policy_service_test.gofirst:Geton an empty table seeds from config defaults and returns them.Updatevalidatesmfa_modein{disabled,optional,required},device_trust_ttl_secondsin[3600, 7776000],max_trusted_devicesin[1,100], andsession_token_ttl_seconds >= 0.Updatewrites anauth.policy.updateaudit row containing the changed keys and their new non-secret values.- Setting
mfa_mode=disabledwhile at least one user hasmfa_required=1succeeds and the per-user override still applies; assert this throughResolvePolicy.
- Write
internal/server/sso_api_test.gofirst:- a non-admin principal receives
403on every/sso/providersroute, - unauthenticated requests receive
401, GET /sso/providersreturns the envelope withitems,nextCursor,hasMoreand honourslimit,POSTwithoutIdempotency-Keyreturns400 {"error":"idempotency_key_required"},- a replayed key returns the original response body and status,
PATCHwith an unknown id returns404,GET /auth/policyis admin-only andPUT /auth/policyrequiresIdempotency-Key,- error responses use the documented stable
data.errorstrings.
- a non-admin principal receives
- Run
go test ./internal/auth ./internal/server -run 'OIDCProvider|AuthPolicy|SSO' -count=1and confirm compile failures. Record the output. - Implement
internal/auth/oidc_provider_service.goandinternal/auth/auth_policy_service.go. - Refactor the duplicated secret-store bootstrap in
internal/server/credential_service.goandinternal/server/token_service.gointo one exported helperserver.NewSecretProvider() (auth.SecretProvider, error)and use it from the new services. This removes the third copy of the same environment lookup and keeps behaviour identical. - Implement
internal/server/sso_api.gohandlers and registercase "sso":plus theauth/policysub-route inAPI.ServeHTTP. Handlers decode, authorize withisAdmin, delegate, and map sentinel errors to status codes through a newwriteAuthErrorhelper. - Run
go test ./internal/auth ./internal/server -count=1and confirm green.
Verification:
go test ./internal/auth ./internal/server -count=1
go test -race ./internal/auth ./internal/server
go vet ./internal/auth ./internal/server
Expected: all pass.
Rollback: Delete the new files, revert the ServeHTTP case additions and the NewSecretProvider refactor. Providers already stored stay in the database but become unreachable.
Task 9: Authentication HTTP endpoints
Files:
- Create:
internal/server/auth_api.go - Create:
internal/server/auth_oidc_api.go - Create:
internal/server/auth_api_test.go - Create:
internal/server/auth_oidc_api_test.go - Modify:
internal/server/api.go - Modify:
internal/server/middleware.go - Modify:
internal/auth/service.go
Interfaces:
- Produces: handlers for
POST /auth/login,POST /auth/mfa/verify,GET /auth/mfa,POST /auth/mfa/enroll,POST /auth/mfa/enable,DELETE /auth/mfa,GET /auth/devices,DELETE /auth/devices/{id},GET /auth/identities,DELETE /auth/identities/{id}. - Produces: handlers for
GET /auth/oidc/providers,GET /auth/oidc/{name}/authorize,GET /auth/oidc/{name}/callback,POST /auth/oidc/exchange. - Produces:
clientIP(r *http.Request) stringininternal/server/middleware.gohonouringX-Forwarded-Foronly when the direct peer is a configured trusted proxy; otherwise it usesRemoteAddr. The trusted-proxy list is a newserver.trusted_proxiesconfig field. - Produces:
writeAuthError(w, err)mapping everyinternal/authsentinel to a status code and a stabledata.errorstring. - Consumes: Tasks 4, 5, 6, 7, 8.
Steps:
- Write
internal/server/auth_api_test.gofirst:POST /auth/loginwith MFA disabled returns the exact legacy shape{token,user}plusdeviceTrusted, so an existing client keeps working.- with
mfa_mode=requiredit returns200and{mfaRequired:true, challengeId, methods, expiresAt}and no token. - a throttled bucket returns
429with aRetry-Afterheader anddata.error="login_throttled". - invalid credentials return
401 {"error":"invalid_credentials"}identically for unknown user, disabled user, and bad password. POST /auth/mfa/verifywith a valid code returns{token,user,deviceTrusted}and sets thetm_devicecookie whentrustDeviceis true.POST /auth/mfa/verifywith an unknown challenge returns401 {"error":"mfa_challenge_invalid"}; exhausted attempts return401 {"error":"mfa_attempts_exceeded"}.GET /auth/mfarequires authentication and reports{status,remainingRecoveryCodes,policy}.POST /auth/mfa/enrollreturns{secret,otpauthUrl,recoveryCodes}and the codes appear exactly once across two calls with different sessions.DELETE /auth/mfaundermfa_mode=requiredreturns409 {"error":"mfa_required_by_policy"}.GET /auth/devicesmarks the current cookie device and never returns a hash or token.DELETE /auth/devices/{id}for another user’s device returns403.GET/DELETE /auth/identitieslist and unlink the caller’s external identities; unlinking the only identity of anauth_source=oidcuser with no password returns409 {"error":"identity_required_for_login"}.- every response body is the
{code,msg,data}envelope.
- Write
internal/server/auth_oidc_api_test.gofirst with anhttptestIdP:GET /auth/oidc/providersunauthenticated returns onlyid,name,displayNamefor providers withenabled=1 AND public_listed=1, and returns404whenoidc.public_providers=false.GET /auth/oidc/{name}/authorizereturns302to the IdP withstate,nonce,code_challenge,code_challenge_method=S256,redirect_uri, andCache-Control: no-store; an unknown or disabled provider returns404.GET /auth/oidc/{name}/callbackwith a valid code and state issues alogin_ticketand returns302to the relative path/login?ticket=…; the ticket value never appears in the audit row.- the same callback with MFA required returns
302to/login?mfa=<challengeId>. - a callback with an unknown, expired, or already consumed state returns
400 {"error":"oidc_state_invalid"}and no redirect. - a callback where the IdP returns
error=access_deniedreturns400 {"error":"oidc_provider_error"}and theerror_descriptionis not echoed. - a callback for a user the provider will not provision returns
403 {"error":"oidc_user_not_provisioned"}. POST /auth/oidc/exchangewith a valid single-use ticket returns{token,user}; a second exchange returns401 {"error":"login_ticket_invalid"}.- the redirect
Locationheader is always exactly/login?...and never an absolute external URL, asserted for a provider whoseredirect_uripoints elsewhere.
- Run
go test ./internal/server -run 'AuthAPI|AuthOIDC' -count=1and confirm compile failures. Record the output. - Implement
internal/server/auth_api.go. Replace thepath == "/api/v1/auth/login"special case inAPI.ServeHTTPwith a dispatch that keeps/auth/loginunauthenticated and routes the remainingauth/*sub-paths through the new handlers.GET /auth/meandPUT /auth/passwordkeep their current behaviour and tests. - Implement
internal/server/auth_oidc_api.goincluding the provisioning transaction: identity lookup, optional link to an existing local user, optional JIT creation, role mapping, last-admin protection, thenLoginService.IssueForAuthenticatedUser. - Add
server.trusted_proxies []stringtointernal/configwith validation that each entry parses as an IP or CIDR, and implementclientIP. - Add
storage.UserRepositorysupport for creating a user withpassword_hash='*'andauth_source='oidc'without weakening the existingCreateUservalidation; put that ininternal/auth/login_service.goasProvisionExternalUser. - Run
go test ./internal/server ./internal/auth ./internal/config -count=1and confirm green.
Verification:
go test ./internal/server ./internal/auth ./internal/config -count=1
go test -race ./internal/server
go vet ./...
Expected: all pass, including the legacy login-shape assertion.
Rollback: Revert the ServeHTTP dispatch change to the original login special case and delete the two handler files. Console login returns to password-only.
Task 10: Runtime wiring, sweeper, and metrics
Files:
- Modify:
internal/server/runtime.go - Create:
internal/server/auth_maintenance_sweeper.go - Create:
internal/server/auth_maintenance_sweeper_test.go - Modify:
internal/observability/metrics.go - Modify:
internal/observability/metrics_test.go - Modify:
internal/config/config.go - Modify:
internal/config/config_test.go - Modify:
cmd/tunnelmesh-server/main.go
Interfaces:
- Produces:
NewAuthMaintenanceSweeper(db *storage.DB, interval time.Duration, retention time.Duration) *AuthMaintenanceSweeperwithStart(ctx),Stop(), andSweepOnce(ctx) (challenges int64, devices int64, err error). - Produces:
Metricscounters and gauges listed in spec section 8, exposed through new methodsAuthLogin(method, result string),AuthMFAVerify(method, result string),AuthOIDCStep(step, result string),SetAuthTrustedDevices(float64),SetAuthPendingChallenges(float64),SetAuthBlockedBuckets(float64). - Produces:
config.SecurityConfig.Auth AuthConfigwith the sub-blocks from spec section 4 andconfig.ServerConfig.TrustedProxies []string. - Consumes: Tasks 1-9.
Steps:
- Write
internal/server/auth_maintenance_sweeper_test.gofirst: expired challenges older than the cutoff are deleted and live ones survive; devices pastexpires_ator revoked more thanretentionago are deleted and active ones survive;SweepOnceis safe to call concurrently from two goroutines;Startstops promptly when the context is cancelled. - Write
internal/config/config_test.goadditions first: defaults match spec section 4;TUNNELMESH_SECURITY_AUTH_MFA_ISSUERoverrides the file value;mfa.digits=7is rejected;oidc.login_ticket_ttl=1his rejected;device_trust.cookie_same_site=nonewithoutcookie_secureis rejected;trusted_proxieswith a malformed CIDR is rejected. - Write
internal/observability/metrics_test.goadditions first asserting the six new series are registered and labelled as documented. - Run
go test ./internal/server ./internal/config ./internal/observability -run 'Sweeper|Auth' -count=1and confirm failures. Record the output. - Implement the sweeper following the existing
client_metadata_sweeper.golifecycle pattern. Sweeping is an idempotentDELETE, so every node may run it; no distributed lock is needed and none is added. - Implement the metrics methods and the config block.
- Wire
LoginService,MFAService,DeviceService,OIDCProviderService,AuthPolicyService, and the sweeper intoNewServerRuntimeand pass them toNewAPIthrough a newSetIdentityServicessetter so existingNewAPI(db, auth)callers and tests keep compiling. - Start and stop the sweeper in
cmd/tunnelmesh-server/main.goalongside the existing background loops. - Run
go test ./... -count=1and confirm green.
Verification:
go test ./internal/server ./internal/config ./internal/observability -count=1
go test ./... -count=1
go vet ./...
Expected: all pass.
Rollback: Remove the SetIdentityServices call and the sweeper start. Handlers return 503 {"error":"identity_services_unavailable"} when the services are nil, which is the same fail-closed posture used by credentials today.
Task 11: Console
Files:
- Modify:
web/src/api/client.ts - Create:
web/src/api/auth.ts - Modify:
web/src/stores/auth.ts - Modify:
web/src/views/Login.vue - Modify:
web/src/views/AccountSecurity.vue - Modify:
web/src/views/Users.vue - Create:
web/src/views/SSOProviders.vue - Modify:
web/src/router.ts - Modify:
web/src/layouts/AppShell.vue - Modify:
web/src/layouts/breadcrumbs.ts - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts - Create:
web/src/tests/login-mfa.spec.ts - Create:
web/src/tests/account-security-mfa.spec.ts - Create:
web/src/tests/sso-providers.spec.ts - Modify:
web/src/tests/accounts.spec.ts - Modify:
web/src/tests/i18n.spec.ts
Interfaces:
- Produces:
LoginResultdiscriminated union{state:'authenticated', token, user, deviceTrusted} | {state:'mfa_required', challengeId, methods, expiresAt} | {state:'throttled', retryAfter}. - Produces:
useAuthStoreactionslogin,verifyMfa,exchangeTicket,load,logout;loginresolves toLoginResultinstead of throwing on MFA. - Produces:
web/src/api/auth.tswithgetMFAStatus,enrollMFA,enableMFA,disableMFA,listDevices,revokeDevice,listIdentities,unlinkIdentity,listOIDCProviders,exchangeOIDCTicket,getSSOProviders,createSSOProvider,updateSSOProvider,deleteSSOProvider,testSSOProvider,getAuthPolicy,updateAuthPolicy,resetUserMFA,listUserDevices,revokeUserDevice. - Produces: route
/sso-providersguarded bymeta:{auth:true, admin:true}. - Consumes: Task 9 endpoints.
Steps:
- Write the three new spec files first, asserting:
Login.vuerenders SSO buttons fromlistOIDCProviders, and a second step with a 6-digit input and a “trust this device” checkbox aftermfa_required.- on mount with
?ticket=in the query,Login.vuecallsexchangeOIDCTicketonce, stores the token, replaces the URL withrouter.replace('/'), and never writes the ticket tolocalStorage. - on mount with
?mfa=it renders the MFA step pre-bound to that challenge id. AccountSecurity.vueshows enrollment with theotpauth://URL, a copy button, the recovery codes exactly once behind an acknowledgement checkbox, and a trusted-device list with revoke actions.SSOProviders.vuelists providers withhasSecretinstead of a secret value, edits with an empty-secret placeholder meaning “keep”, and exposes a Test action that rendersTestReport.Users.vueexposes anmfaRequiredswitch and a “Reset MFA” action requiring confirmation.- a throttled login renders
retryAfterand disables the submit button until it elapses.
- Run
cd web && npm test -- --runand confirm the three new specs fail. Record the output. - Add the Chinese keys to
web/src/i18n/messages/zh-CN.tsfirst, then mirror them exactly inen-US.ts. Extendweb/src/tests/i18n.spec.tswith an assertion that the two message trees have identical key paths so future drift fails the build. - Implement
web/src/api/auth.ts, the store changes, and the four views. Keep Element Plus components and the existing compact single-line template style. - Add the
/sso-providersroute, the navigation entry, and the breadcrumb label. - Run
cd web && npm test -- --run && npm run buildand confirm green, then run./scripts/verify-web-embed.shafter copying the build output into the embed directory used byinternal/server/web.go.
Verification:
cd web && npm test -- --run && npm run build
./scripts/verify-web-embed.sh
Expected: all frontend tests pass, the Vite build succeeds, and the embed verification reports no missing asset.
Rollback: Revert the console commit. The API remains usable through curl, and the legacy login shape is unchanged for existing clients.
Task 12: Documentation and OpenAPI
Files:
- Modify:
docs/api/openapi.yaml - Modify:
docs/operations/configuration.md - Modify:
docs/operations/config-examples.md - Modify:
docs/operations/schema-upgrades.md - Modify:
docs/operations/observability.md - Modify:
docs/user-guide/server-admin.md - Create:
docs/user-guide/sso-and-mfa.md - Modify:
docs/en/operations/security.md - Modify:
docs/en/user-guide/server-admin.md - Create:
docs/en/user-guide/sso-and-mfa.md - Modify:
docs/README.md - Modify:
docs/index.md - Modify:
docs/community/roadmap.md - Modify:
README.md - Modify:
README.zh-CN.md - Modify:
deploy/README.md - Modify:
docs/superpowers/plans/README.md(regenerated) - Modify:
docs/superpowers/specs/README.md(regenerated) - Create:
docs/pull-requests/2026-09-18-sso-mfa-device-trust.md
Interfaces:
- Produces: OpenAPI paths and schemas for every endpoint added in Tasks 8 and 9.
- Produces: a user-facing SSO/MFA guide with an IdP configuration walkthrough for a generic OIDC provider, listing the exact redirect URI format
https://<server>/api/v1/auth/oidc/<provider>/callback. - Consumes: the final implemented behaviour.
Steps:
- Add every new path, request body, response schema, and
data.errorenumeration value todocs/api/openapi.yaml, keeping the existing component style and the{code,msg,data}envelope schema. - Document the
security.authblock,server.trusted_proxies, theTUNNELMESH_TOKEN_ENCRYPTION_KEYrequirement for SSO and MFA, and the “config seeds defaults, database is authoritative afterwards” rule indocs/operations/configuration.md. - Add a cluster example with SSO and MFA enabled to
docs/operations/config-examples.md. - Extend
docs/operations/schema-upgrades.mdwith the v13 → v14 upgrade and rollback procedure, expected lock impact (additiveALTER TABLEplus new tables), the pre-upgrade backup requirement, and the feature-level rollback that needs no deploy. - Add the six new metric families to
docs/operations/observability.mdwith suggested alert thresholds. - Write
docs/user-guide/sso-and-mfa.mdand its English mirror: enrolling MFA, storing recovery codes, trusting and revoking devices, administrator provider setup, role mapping, troubleshooting table keyed by the stabledata.errorvalues, and the lockout recovery path through the existing administrator bootstrap flow. - Link the new guide from
docs/user-guide/server-admin.md,docs/README.md,docs/index.md,docs/en/user-guide/server-admin.md, and both root READMEs. - Update
docs/community/roadmap.mdto mark SSO/OIDC and MFA/device trust as shipped and the remaining seven capabilities as planned, without inventing dates. - Update the capability matrix in
README.mdandREADME.zh-CN.mdidentically. - Create
docs/pull-requests/2026-09-18-sso-mfa-device-trust.mdwith title, target branchmain, summary, user impact, API/schema/config impact, security and authorization impact, test evidence, release steps, rollback steps, reviewer focus areas, and integration status. Include no secrets and no unredacted logs. - Run
python3 scripts/gen_doc_index.pyand confirm the plans, specs, and PR indexes pick up the three new records with correct cross-links. - Confirm no orphan document: every new file is reachable from
docs/README.mdordocs/index.md.
Verification:
python3 scripts/gen_doc_index.py
git diff --check
grep -rn "TBD\|TODO\|待补充" docs/superpowers/plans/2026-09-18-sso-mfa-device-trust-implementation.md docs/superpowers/specs/2026-09-18-sso-mfa-device-trust-design.md
Expected: the index regenerates with no diff churn on unrelated files; git diff --check prints nothing; the grep finds no placeholder.
Rollback: Revert the documentation commit. Docs have no runtime effect.
Task 13: Full validation
Files:
- Modify:
docs/pull-requests/2026-09-18-sso-mfa-device-trust.md
Steps:
- Run the complete Go validation set and record the real output in the PR record:
go build ./...
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
- Run the complete frontend validation set and record the real output:
cd web && npm test -- --run && npm run build
cd .. && ./scripts/verify-web-embed.sh
- Run a manual smoke pass against a local SQLite server with
auto-initenabled: bootstrap admin login, enroll MFA, verify with a code fromotpauth-url, trust a device, log in again with the cookie, revoke the device, create an OIDC provider pointing at a local test IdP, complete the redirect flow, and confirm each step produced the expected audit row and metric sample. Record the observed audit actions in the PR record with all identifiers redacted. - Confirm
go.modandgo.sumare unchanged, proving no new dependency was added. - Confirm no secret appears in the diff:
git diff --cached | grep -Ein "BEGIN [A-Z ]*PRIVATE KEY|client_secret\"?\s*[:=]\s*\"[A-Za-z0-9]|tmrc-[A-Z2-7]{20}" || echo "no secret pattern found"
- Do not commit, push, merge, or open a PR until the user explicitly authorizes it.
Verification: the five Go commands, the two frontend commands, and the secret scan all produce the recorded output.
Rollback: the whole phase is one feature branch; revert the branch. Databases already at v14 keep working with a v14 binary and require a backup restore to run a v13 binary, as documented in docs/operations/schema-upgrades.md.