docs(19.6-03): draft summary at the Task 4 release checkpoint

- WebAuthn and passkeys committed in sm-mfa-plugin (2662252, 6c8916f),
  demo two-factor UI in vue-demo-app (44a5229)
- sociallogin D-09 change kept as a patch: the checkout is jin's
- deferred items: sociallogin gofmt finding, no demo type check
This commit is contained in:
2026-10-26 12:49:19 +01:00
parent 5d78f53ddc
commit 5a6461123c
3 changed files with 840 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
plan: 03
subsystem: auth
tags: [sm-mfa-plugin, webauthn, go-webauthn, passkeys, passwordless, sm-sociallogin-plugin, login-gate, vue-demo-app, totp-ui, draft]
requires:
- phase: 19.6-01
provides: sm-user-plugin login gates (CheckLoginGates, LoginSourceSocial, LoginSourcePasskey), FinishLogin/IssueToken/APIArray/FireLoginEvent, RejectIfThrottled, ThrottleStates
- phase: 19.6-02
provides: sm-mfa-plugin core (challenge store, attempt/completeSignIn/withTrustedDevice, EnableMethod/DisableMethod, EnsureRecoveryCodes, Status, Admin, migration slot 202610220006)
provides:
- WebAuthn security keys as a second factor in sm-mfa-plugin (go-webauthn v0.18.2, standard base64url shapes, D-08)
- golem15_user_webauthn_credentials (migration 202610220006) with credential_data and passwordless
- Passwordless passkey sign-in behind two_factor_passwordless_login (D-14)
- D-09 social-login gate in sm-sociallogin-plugin, prepared as commit a0161d7 in a scratch clone (not yet in the real checkout, see Blockers)
- Demo two-factor UI in vue-demo-app (TOTP setup, recovery codes, disable, login challenge; D-20)
affects: [19.6-04, sm-demo-app, sm-bm-app, fonoteka.go, vue-demo-app, sm-sociallogin-plugin]
actuals:
tokens: 35354 # chars/4 over the added lines of this plan's diffs in the three repositories (go.sum and go.work.sum excluded)
tasks: 3 # Tasks 1-3 of 6; Task 4 is the open checkpoint
commits: 0 # MEASURED in summercms.go before this draft's commit: git rev-list --count 5d78f53..HEAD (code lives in other repositories)
sm_mfa_plugin_commits: 2 # MEASURED: git -C ../sm-mfa-plugin rev-list --count 8a63f6f..HEAD
vue_demo_app_commits: 1 # MEASURED: git -C ../sm-demo-app/vue-demo-app rev-list --count 9c247ab..HEAD
sm_sociallogin_plugin_commits: 0 # the real checkout is unchanged; 1 commit (a0161d7) in the scratch clone
plan_head_before: 5d78f53ddc890ef53fe52da1828f3f88d9923270
plan_head_after: 5d78f53ddc890ef53fe52da1828f3f88d9923270
sm_mfa_plugin_head_before: 8a63f6f
sm_mfa_plugin_head_after: 6c8916f
vue_demo_app_head_before: 9c247ab
vue_demo_app_head_after: 44a5229
sm_sociallogin_plugin_head: 4d3b2dd # unchanged (v0.1.3 = origin/master); patch prepared on top of it
sm_user_plugin_head: 3b22134 # unchanged, untagged, 5 commits ahead of origin (v0.1.11 = 50da92c)
tech-stack:
added:
- "github.com/go-webauthn/webauthn v0.18.2 (packages webauthn, protocol), sm-mfa-plugin only (approved 2026-10-26); brings fxamacker/cbor/v2, go-webauthn/x, google/go-tpm, tinylib/msgp as indirect modules"
patterns:
- "WebAuthn ceremony state server-side: registration and passkey state in cooler consumed with Pull; login state on the challenge row"
- "Pure verification helpers (VerifyRegistration, VerifyAssertion) shared by handlers and a no-database smoke test"
- "Virtual authenticator test helper (ECDSA P-256, attestation none, CBOR through go-webauthn's webauthncbor)"
key-files:
created:
- ../sm-mfa-plugin/classes/webauthn.go
- ../sm-mfa-plugin/classes/passwordless.go
- ../sm-mfa-plugin/controllers/webauthn.go
- ../sm-mfa-plugin/controllers/passwordless.go
- ../sm-mfa-plugin/models/webauthn_credential.go
- ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go
- ../sm-mfa-plugin/webauthn_authenticator_test.go
- ../sm-mfa-plugin/webauthn_smoke_test.go
- ../sm-mfa-plugin/passwordless_smoke_test.go
- ../sm-mfa-plugin/go.work.sum
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts
- ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-sociallogin-gate.patch
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/deferred-items.md
modified:
- ../sm-mfa-plugin/{go.mod,go.sum,routes.go,plugin.go,README.md,config/config.yaml,lang/en/lang.yaml,lang/pl/lang.yaml}
- ../sm-mfa-plugin/classes/{config.go,methods.go,admin.go,events.go,login.go}
- ../sm-demo-app/vue-demo-app/app/{composables/useDemoAuth.ts,pages/login.vue,components/SiteHeader.vue}
- ../sm-demo-app/vue-demo-app/i18n/locales/{en.json,pl.json}
- "(patch only) ../sm-sociallogin-plugin/{classes/flow.go,classes/codes.go,controllers/callback.go,controllers/complete.go,controllers/register_complete.go,lang/en/lang.yaml,lang/pl/lang.yaml,README.md}, new social_gate_smoke_test.go"
key-decisions:
- "WebAuthn RP from golem15.mfa.webauthn.rp_id / rp_name / origins (defaults: app.url host, app.name, app.url and golem15.user.frontend_url origins); an IP host or no origin makes WebAuthnFor answer ErrWebAuthnUnavailable, and the routes answer 403 'WebAuthn is not available.'"
- "Assertion verification is split: FinishAssertion verifies outside the transaction, RecordAssertion writes the record back in the completing transaction with a guarded UPDATE on the old sign_count (a lost race refuses the login)"
- "Registration answers 'Registration failed: <reason>' with fixed reasons: invalid or expired challenge, invalid response, verification failed, credential already registered"
- "Passkey routes also answer 403 while two_factor_mode is disabled (fail closed), not only while two_factor_passwordless_login is off"
- "Passkey sign-in refuses a ban or suspension from any address (ThrottleStates) and from the request's address (RejectIfThrottled)"
- "A challenged social sign-in does not fire SignedInEvent and does not restore a deactivated account; the identity is stored before the gate"
- "No framework tag needed: sm-mfa-plugin (with the user plugin at 3b22134) and the sociallogin change pass their full suites against the framework's v0.5.0 tag"
patterns-established:
- "Plugin work in a checkout the executor cannot write: implement and test in a scratch clone with a GOWORK file, export git format-patch, keep the patch in the phase directory"
requirements-completed: [] # draft; the plan's requirements are copied when the plan completes after Task 6
coverage:
- id: D1
description: "WebAuthn second factor: register options (attestation none), register with PHP's answer and first recovery codes, single-use registration state, status list, login offering webauthn, verify before options refused, assertion completing the challenge with the sign count written back, clone refused, last key removal disabling the method, admin reset deleting keys, 403 while unavailable"
requirement: "SC-1"
verification:
- kind: unit
ref: "../sm-mfa-plugin/webauthn_smoke_test.go#TestWebAuthnCeremonySmoke"
status: pass
- kind: integration
ref: "../sm-mfa-plugin/webauthn_smoke_test.go#TestWebAuthnSmoke"
status: pass
human_judgment: false
- id: D2
description: "Passwordless passkey sign-in: 403 while off, passkey signs in with one passkey login event, second-factor key / no credProps / no UV / foreign handle / banned / unactivated / reused challenge refused with one 401"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-mfa-plugin/passwordless_smoke_test.go#TestPasswordlessSmoke (7 subtests)"
status: pass
human_judgment: false
- id: D3
description: "Social sign-in through the login gate (prepared, not yet in the real sm-sociallogin-plugin checkout)"
requirement: "D-09"
verification:
- kind: integration
ref: "scratch clone a0161d7: social_gate_smoke_test.go#TestSocialGateSmoke (5 subtests) and TestTracerSmoke"
status: pass
human_judgment: true
rationale: "The change is not committed in ../sm-sociallogin-plugin yet (owned by jin); it must be applied and the tests re-run there."
- id: D4
description: "Demo two-factor UI: TOTP setup with the QR image, recovery codes shown once, turn off, login challenge with TOTP or recovery code"
requirement: "D-20"
verification:
- kind: unit
ref: "../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts (14 tests)"
status: pass
- kind: other
ref: "pnpm build; server render of /account/security and /login on the built output (200)"
status: pass
human_judgment: true
rationale: "Look and flow in a browser against a running demo with golem15.mfa mounted (Task 6) are a visual judgement; WebAuthn is not part of the demo UI."
duration: 40min
completed: 2026-10-26
status: draft
---
# Phase 19.6 Plan 03: WebAuthn and adoption Summary (draft)
**Status:** draft (checkpoint Task 4). Tasks 1-3 are done. Task 4 waits for the user's release decision. Tasks 5 and 6 run in a continuation.
**Results so far:**
- **sm-mfa-plugin:** WebAuthn security keys as a second factor, using go-webauthn v0.18.2 with server-side ceremony state, and passwordless passkey sign-in behind its setting. Two local commits.
- **sm-sociallogin-plugin:** the social-login gate (D-09) is written and tested in a scratch clone. It is not in the real checkout yet, because that checkout belongs to the user `jin`.
- **vue-demo-app:** the demo's TOTP setup, recovery-code and login-challenge screens. One local commit.
- **Remote:** nothing was pushed, tagged or created.
## Performance
- **Started:** about 2026-10-26T11:08Z
- **Checkpoint:** 2026-10-26T11:48Z (about 40 min)
- **Tasks:** 3 of 6 (Task 4 is the open checkpoint)
- **Files:**
- sm-mfa-plugin: 23 files, +2358/-37
- vue-demo-app: 9 files, +549/-13
- sociallogin patch: 9 files, +266/-16
## Commits per repository
| Task | Repository | Commit | Message |
|---|---|---|---|
| 1 (tracer) | sm-mfa-plugin (`../sm-mfa-plugin`, no remote) | `2662252` | feat: WebAuthn security keys as a second factor |
| 2 | sm-mfa-plugin | `6c8916f` | feat: passwordless passkey login |
| 2 | sm-sociallogin-plugin, **scratch clone only** | `a0161d7` (patch `19.6-03-sociallogin-gate.patch`, sha256 `bc0228df…5db72`) | feat: social sign-in through the login gate |
| 3 | vue-demo-app (`../sm-demo-app/vue-demo-app`) | `44a5229` | feat: two-factor setup and the login challenge |
- The real `../sm-sociallogin-plugin` is unchanged: `4d3b2dd` = v0.1.3 = origin/master.
- The patch is at `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-sociallogin-gate.patch` and applies on `4d3b2dd` with `git am`.
- The scratch clone is at `/tmp/claude-1002/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/c0ca11bb-a138-4d09-8120-38eddba1c3cf/scratchpad/sociallogin-clone`.
- Tracer feedback gate (interactive, end-of-phase, verify fully automated): both Task 1 verify commands ran green on the committed code before Task 2 started.
## Task 4 reply
(pending: awaiting the user's reply to the checkpoint)
## Tests
All Docker-backed tests ran against testcontainers Postgres (Docker reachable as golem).
- **sm-mfa-plugin:**
- `go vet ./...` and `go test -count=1 -short ./...`: green at both commits.
- `TestWebAuthnCeremonySmoke` (short): pass.
- `TestWebAuthnSmoke`, `TestTracerSmoke`, `TestChallengeSmoke`, `TestTrustedSettingsSmoke`: 4 of 4 pass.
- `TestPasswordlessSmoke` (7 subtests) and `TestWebAuthnSmoke`: 2 of 2 pass.
- Full `go test ./...`: ok. It was also run against the framework's v0.5.0 tag (exported with `git archive` into the scratchpad): ok.
- **sm-sociallogin-plugin (scratch clone, GOWORK pointing at the real summercms.go and user-plugin checkouts):**
- `go vet`: green.
- `-short` suite: ok.
- `TestSocialGateSmoke` (5 subtests) and `TestTracerSmoke`: 2 of 2 pass.
- Full suite: ok, against both the local framework and v0.5.0.
- **NOT RUN in the real checkout**, which cannot be written.
- **vue-demo-app:**
- `pnpm test`: 17 files, 202 tests pass, including `twoFactor.test.ts` with 14 tests.
- `pnpm build`: ok.
- Server render of `/account/security` and `/login` on `.output`: 200.
- **NOT RUN:**
- Type check: no vue-tsc or `nuxi typecheck` is set up (deferred-items.md).
- A browser run against a demo with golem15.mfa mounted: that needs Task 6.
## Exported identifiers (for plan 04 and the continuation)
**sm-mfa-plugin `classes`:**
- **Relying party and handles:**
- `WebAuthnFor(ctx, app) (*webauthn.WebAuthn, error)`
- `UserHandle(id uint) []byte`, `UserIDFromHandle`
- **User adapter:** `WebAuthnUser` and `NewWebAuthnUser(ctx, db, user)`.
- **Registration:**
- `BeginRegistration(ctx, app, cache, db, user, passwordless) (map[string]any, error)`
- `FinishRegistration(ctx, app, cache, tx, user, challenge, attestationJSON, name) (*models.WebAuthnCredential, error)`
- `VerifyRegistration(w, wu, session, attestationJSON)`
- **Second-factor assertion:**
- `BeginAssertion(ctx, app, db, challenge, user)`
- `FinishAssertion(ctx, app, db, challenge, user, assertionJSON) (*Assertion, error)`
- `VerifyAssertion(w, wu, session, assertionJSON)`
- `RecordAssertion(ctx, tx, *Assertion) (bool, error)`, `Assertion`
- **Passkeys:**
- `BeginPasswordless(ctx, app, cache)`
- `FinishPasswordless(ctx, app, cache, db, challenge, assertionJSON, ip) (*usermodels.User, *Assertion, error)`
- **Credential helpers:** `WebAuthnCredentialList`, `HasWebAuthnCredential`, `DeleteWebAuthnCredential`.
- **Login:** `CompleteLoginAs(ctx, app, db, r, user, source)`.
- **Constants:** `WebAuthnTimeout` (60 s), `WebAuthnStateTTL` (120 s).
- **Errors:**
- `WebAuthnFailure{Reason}` with `ErrWebAuthnChallenge`, `ErrWebAuthnInvalid`, `ErrWebAuthnVerification`, `ErrWebAuthnDuplicate`, `ErrWebAuthnNoOptions`, `ErrWebAuthnNoCredentials`
- `ErrWebAuthnUnavailable`
- **Events:**
- `WebAuthnRegisteredEvent{User, Credential, Tx}`, `WebAuthnRegisteredEventName` = `golem15.user.2fa.webauthn.registered`
- `WebAuthnRemovedEvent{User, CredentialID, Tx}`, `WebAuthnRemovedEventName` = `golem15.user.2fa.webauthn.removed`
- **Config fields:** `Config.WebAuthnRPID`, `WebAuthnRPName`, `WebAuthnOrigins`.
**Other sm-mfa-plugin packages:**
- **`controllers`:** `WebAuthnRegisterOptions`, `WebAuthnRegister`, `WebAuthnRemove`, `WebAuthnOptions`, `WebAuthnVerify`, `PasswordlessOptions`, `PasswordlessVerify`.
- **`models`:** `WebAuthnCredential` (`DisplayName()`), `DefaultCredentialName`.
- **Migration:** `202610220006_create_golem15_user_webauthn_credentials`.
- **Config keys:** `golem15.mfa.webauthn.rp_id`, `golem15.mfa.webauthn.rp_name`, `golem15.mfa.webauthn.origins` (env `SUMMER_GOLEM15__MFA__WEBAUTHN__RP_ID`, `…__RP_NAME`, `…__ORIGINS`).
**sm-mfa-plugin routes:**
- **Challenge group, `throttle:2fa-verify`:**
- `POST /_user/api/v1/2fa/webauthn/options`, `…/webauthn/verify`
- `…/webauthn/passwordless/options`, `…/webauthn/passwordless/verify`
- **Management, `jwt.auth`:**
- `POST …/webauthn/register/options`, `POST …/webauthn/register`
- `DELETE …/webauthn/{id}` (digits)
**sm-sociallogin-plugin (patch):**
- `classes.Outcome.Challenge` and `classes.CompletionPayload.Challenge` (JSON `challenge,omitempty`).
- Lang key `golem15.sociallogin::lang.oauth.two_factor_unavailable`.
- Test `TestSocialGateSmoke`.
- go.mod still requires sm-user-plugin v0.1.5, with go.work replacing it until Task 5.
**vue-demo-app:**
- **`app/lib/twoFactor.ts`:**
- Functions `isTwoFactorChallenge`, `normalizeRecoveryCode`, `isTotpCode`, `challengeExpired`, `svgDataUrl`.
- Types `TwoFactorChallenge`, `TwoFactorStatus`, `TotpSetup`, `WebAuthnCredentialInfo`.
- Constant `CHALLENGE_TOKEN_LENGTH`.
- **Composables:**
- `useDemoAuth()` gains `verifyChallenge` and `recoverChallenge`, and `login` returns `{challenge?}`.
- `useTwoFactor()` (`status`, `setupTotp`, `confirmTotp`, `disableTotp`).
- **UI:**
- Page `/account/security`.
- Header link to it.
- i18n `auth.twoFactor.*`, `security.*`, `header.security` (en, pl).
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Auto-fixed and drift
**1. [Blocking: environment, unresolved] sm-sociallogin-plugin cannot be written**
- **Found during:** Task 2.
- **Issue:** `../sm-sociallogin-plugin` is owned by `jin` (`drwxrwxr-x jin jin`); golem is not in the jin group, and git refuses the checkout as "dubious ownership". As instructed, I did not use sudo and did not write into it.
- **What I did instead:**
- Implemented D-09 in a scratch clone of `4d3b2dd`.
- Tested it there with a GOWORK file pointing at the real summercms.go and user-plugin checkouts. `../fonoteka.go` and `../summercms.go` are symlinked next to the clone so the relative paths in its tests resolve.
- Committed it there as `a0161d7` with the plan's message and exported it as `19.6-03-sociallogin-gate.patch` in this phase directory.
- **Remaining:** `git am` in the real checkout (by jin, or after the user grants golem write access), then rerun its suite there. Task 5 step 3 (require the new user tag, push, tag) needs the same access.
**2. [Drift] Release numbers**
- **sm-user-plugin:** origin's newest tag is v0.1.11 (`50da92c`). Local master `3b22134` is 5 commits ahead: `8501f34`, `7900f47`, `3d4c8d2`, `4e3e5b0`, `3b22134`. The plan's "v0.1.6" is stale; the next free patch is **v0.1.12**.
- **sm-sociallogin-plugin:** origin's newest tag is v0.1.3 (`4d3b2dd`, Phase 19.8's SignedInEvent). The plan's "v0.1.3" is taken; the next free patch is **v0.1.4**.
- **Framework:** the newest tag is v0.5.0. sm-mfa-plugin and sm-user-plugin require it. sociallogin requires v0.3.1, and after its require bump to user v0.1.12, `go mod tidy` will raise that to v0.5.0.
- **No framework tag is needed.** Both plugins pass against the v0.5.0 tag.
**3. [Drift] vue-demo-app carries another phase's unpushed commits**
- The plan expected `5a8d544`. The checkout is at `9c247ab`, 4 commits ahead of origin/master `21a22b6`: Phase 19.7's `0125e29`, `b973a76`, `3c53182`, `9c247ab` (MCP consent and link-admin pages, 2026-10-23).
- Task 3 committed on top (`44a5229`). Pushing vue-demo-app also pushes those four, so the Task 5 pre-push rule requires the reply to name them.
**4. [Drift] sm-demo-app state for Task 6**
- master is 19 commits ahead of origin (other phases).
- Its working tree has another session's uncommitted submodule pointer moves for `plugins/golem15/journal`, `plugins/golem15/oauthserver` and `plugins/golem15/user` (user checked out at v0.1.11 while go.mod still requires v0.1.10).
- The `vue-demo-app` gitlink now shows as modified because of `44a5229`.
- Task 6 must not stage the other sessions' pointers. A push needs the reply to name the 19 commits.
**5. [Rule 2 - Security] Availability check on webauthn/register too**
- PHP checked availability only on register/options.
- `register` also answers 403 while webauthn is not an available method, as `totp/confirm` does since plan 02.
- **Commit:** `2662252`.
**6. [Rule 2 - Correctness] Assertion write-back is guarded**
- The credential record is written back with `WHERE sign_count = <read value> AND is_enabled`, inside the completing transaction.
- A concurrent write or a disabled key refuses the login instead of losing the counter.
- **Commit:** `2662252`.
**7. [Discretion] Function split**
- The plan named `FinishAssertion(ctx, app, tx, …)` as the one step that verifies and writes.
- It is now `FinishAssertion` (verify, no write, outside the transaction, like the TOTP path), followed by `RecordAssertion` (guarded write in the transaction).
- `VerifyRegistration` and `VerifyAssertion` are exported pure helpers, so the no-database smoke test exercises the handlers' exact code.
- `CompleteLoginAs` was added for the passkey source.
**8. [Rule 2 - Security, fail closed] Passkey routes 403 while two_factor_mode is disabled**
- This is not only tied to `two_factor_passwordless_login`.
- **Commit:** `6c8916f`.
**9. [Rule 2 - Security] Passkey ban check across addresses**
- `ThrottleStates` (a ban or active suspension on any row) runs in addition to `RejectIfThrottled` with the request's address.
- An unactivated account is refused too (tested).
- **Commit:** `6c8916f`.
**10. [Scope] Extra test coverage**
- `TestWebAuthnSmoke` also checks the admin reset deleting keys and the 403 while webauthn is unavailable.
- `TestPasswordlessSmoke` adds `wrong_handle_refused` and an unactivated user.
- `TestSocialGateSmoke` also covers `oauth-register-complete` answering the challenge.
**11. [Scope] Demo UI extras**
- Additional i18n keys beyond the plan's list:
- `auth.twoFactor.busy`, `auth.twoFactor.recoveryPrompt`
- `security.eyebrow`, `security.qrAlt`, `security.cancel`, `security.codeInvalid`, `security.unavailable`
- `header.security`
- The setup form has a cancel link.
---
**Total deviations:** 11 (1 unresolved blocker, 3 drifts, 4 Rule 2 fixes, 1 discretion, 2 scope).
**Impact on plan:** Every must-have truth of Tasks 1 and 3 holds in committed code. D-09 holds in the prepared patch, but not yet in the real repository. No locked CONTEXT decision changed.
## Known Stubs
None. Plan 02's two stubs are closed:
- `status.webauthn_credentials` lists the enabled keys.
- `Admin.Reset` deletes WebAuthn credentials, and `HasTwoFactor` counts them.
## Threat Flags
None beyond the plan's register. T-19.6-26..31 and T-19.6-33 are mitigated in code and covered by the tests above. T-19.6-32 (remote operations) is the open Task 4 decision.
## Blockers
- **sm-sociallogin-plugin write access** (Deviation 1). It blocks applying `a0161d7` and the sociallogin half of Task 5.
- **Task 4 release decision.**
## Next
The continuation agent reads this draft and the Task 4 reply, then:
- (if the reply resolves the sociallogin access) applies the patch;
- runs Task 5 in D-17 order and Task 6;
- finalises this SUMMARY: requirements-completed, status complete, final commits;
- updates STATE and ROADMAP.
g15office: this executor has no g15office tool, so the orchestrator should post the plan 03 update on `avoroeb4`.
## Self-Check: PASSED (draft)
- Commits exist:
- sm-mfa-plugin: 2662252, 6c8916f
- vue-demo-app: 44a5229
- scratch clone: a0161d7
- Working trees of sm-mfa-plugin and vue-demo-app are clean.
- ../sm-sociallogin-plugin is untouched (4d3b2dd, clean).
- sm-mfa-plugin has no remote, and the sm-user-plugin HEAD is untagged.
- No remote operation was performed; only read-only ls-remote calls.

View File

@@ -0,0 +1,468 @@
From a0161d770e3f12d43d335b8c5e026e93f2993786 Mon Sep 17 00:00:00 2001
From: Jakub Zych <jakub@golem15.com>
Date: Mon, 26 Oct 2026 12:39:52 +0100
Subject: [PATCH] feat: social sign-in through the login gate
- signIn consults golem15.user's login gates (CheckLoginGates, source
social) before FinishLogin, SignedInEvent and any token (D-09)
- a 200 gate answer becomes Outcome.Challenge and
CompletionPayload.Challenge; oauth-complete and oauth-register-complete
answer exactly that body, with no token or cookie
- any other status redirects with the new two_factor_unavailable message
(en, pl); a gate error fails closed; action=link is never gated
- README "Two-factor sign-in" section, TestSocialGateSmoke with a fake
gate (no dependency on a two-factor plugin)
---
README.md | 20 +++-
classes/codes.go | 13 ++-
classes/flow.go | 32 +++++-
controllers/callback.go | 9 +-
controllers/complete.go | 9 +-
controllers/register_complete.go | 6 +
lang/en/lang.yaml | 1 +
lang/pl/lang.yaml | 1 +
social_gate_smoke_test.go | 191 +++++++++++++++++++++++++++++++
9 files changed, 266 insertions(+), 16 deletions(-)
create mode 100644 social_gate_smoke_test.go
diff --git a/README.md b/README.md
index db09474..3742c4e 100644
--- a/README.md
+++ b/README.md
@@ -88,13 +88,23 @@ app.Events.Listen[*classes.SignedInEvent]("acme", func(ctx context.Context, ev *
})
```
+### Two-factor sign-in
+
+Every social sign-in except a link consults golem15.user's login gates (`usercontrollers.CheckLoginGates`, source `userclasses.LoginSourceSocial`) before golem15.user's `FinishLogin`, before `SignedInEvent` and before any token exists. Without a gate, or when every gate allows the sign-in, nothing changes. A two-factor plugin (golem15.mfa) registers a gate that challenges social sign-ins only when its social-login setting (`two_factor_social_login`) is on; it is off by default, as in PHP.
+
+- With a challenge, `oauth-complete` (and `oauth-register-complete` when it signs an existing account in) answers 200 with exactly the gate's body, for example `{"two_factor_required":true,"challenge_token":"…","available_methods":["totp"],"expires_at":"…"}`, instead of `{token, user, action, return_to}`. No token and no token cookie are issued; the frontend completes the challenge at the two-factor plugin's routes, which answer `{token, user}`. The SPA callback page must handle `two_factor_required` before an application turns the setting on.
+- A challenged sign-in does not restore a deactivated account and does not fire `SignedInEvent`: the second factor's completion runs golem15.user's `FinishLogin` and its login event (source `two_factor`). The identity itself is stored (or refreshed) before the gate, as the provider already proved it.
+- A gate that refuses (any status other than 200, for example the second-factor failure cap) sends the browser to the callback with `?error=` and the `two_factor_unavailable` message; on `oauth-register-complete` it is a 422 with that message. A gate error fails closed: the callback redirects with the generic error, no completion code is issued, `oauth-register-complete` answers 500.
+- Linking (`action=link`) is never gated: the user is already signed in.
+- Trusted devices never skip a social challenge: a social sign-in carries no request body with a trusted-device token.
+
## Routes
| Method | Path | Middleware | Purpose |
|---|---|---|---|
| GET | `/oauth/{provider}` | `throttle:20,1` | Start: `mode` (ignored, SPA only), `action` (`login` default, `register`, `link`), `frontend_callback`, `return_to`, `link_ticket` |
| GET | `/oauth/{provider}/callback` | `throttle:20,1` | Provider return: `?code=` on success, `?error=<message>` otherwise |
-| GET | `/_user/api/v1/oauth-complete?code=` | `throttle:user-api` | 200 `{token, user, action, return_to}`; 422 empty code; 410 missing or used code |
+| GET | `/_user/api/v1/oauth-complete?code=` | `throttle:user-api` | 200 `{token, user, action, return_to}`, or the login gate's `two_factor_required` body (see Two-factor sign-in); 422 empty code; 410 missing or used code |
| POST | `/_user/api/v1/oauth-register-complete` | `throttle:user-api` | `pending_code`, `terms_accepted`, `privacy_accepted`, `marketing_consent`, optional `parental_authority` and `beta_key` (JSON or form). 200 `{token, user, action, return_to}`; 422 `{error, errors: {terms_accepted, privacy_accepted}}` for missing consent, 422 `{error}` for an unverified email match, a beta key, a closed or throttled registration, 422 `{error, errors}` for a rejected account; 410 for a missing, expired or used code |
`action=register` start requests also take `terms_accepted`, `privacy_accepted`, `marketing_consent` (true for `1`, `true`, `on`, `yes`) and `parental_authority`; any start request may carry `beta_key`.
@@ -155,9 +165,9 @@ Package `classes`:
- `Config`, `LoadConfig`; `ResolveCredentials(ctx, app, name) (ProviderConfig, bool, error)`, `CallbackRoute(name string) string`.
- `State`, `Consent`, `SealState`, `OpenState`, `ClearStateCookie`, `StateCookieName`, `StateCookieSecure`, `StateTTL`, `MaxStateCookieBytes`, `ErrInvalidState`.
- `HostSet`, `NewHostSet`, `NormalizeFrontendCallback`, `NormalizeReturnTo`, `ResolveCallback`, `WithQuery`.
-- `NewCode() (string, error)`, `CompletionPayload`, `PutCompletion`, `PullCompletion`, `CompletionTTL`; `PendingPayload` (and its `Profile()`; `TenantID` carries the tenant, JSON `tenant_id`, omitted when empty), `PutPending`, `GetPending`, `PullPending`, `PendingTTL`; `PutLinkTicket`, `PullLinkTicket`, `LinkTicketTTL`.
+- `NewCode() (string, error)`, `CompletionPayload` (with `Challenge`, JSON `challenge`, omitted when empty), `PutCompletion`, `PullCompletion`, `CompletionTTL`; `PendingPayload` (and its `Profile()`; `TenantID` carries the tenant, JSON `tenant_id`, omitted when empty), `PutPending`, `GetPending`, `PullPending`, `PendingTTL`; `PutLinkTicket`, `PullLinkTicket`, `LinkTicketTTL`.
- `SignedInEvent`, `SignedInEventName` (see Events).
-- `ActionLogin`, `ActionRegister`, `ActionLink`; `Outcome`, `OutcomeLogin`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `HandleCallback`, `CompletePending`, `ErrPendingConsumed`, `CreateUser`, `CheckBetaKey`, `PasswordBootstrapSource`, `ValidationError`, `IsValidation`, `FlowError`, `Message`, `RegisterMessage`, `ClientIP`.
+- `ActionLogin`, `ActionRegister`, `ActionLink`; `Outcome` (with `Challenge`, the login gate's body), `OutcomeLogin`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `HandleCallback`, `CompletePending`, `ErrPendingConsumed`, `CreateUser`, `CheckBetaKey`, `PasswordBootstrapSource`, `ValidationError`, `IsValidation`, `FlowError`, `Message`, `RegisterMessage`, `ClientIP`.
Adding a provider from another plugin (which requires `golem15.sociallogin`):
@@ -182,7 +192,7 @@ func (p *Plugin) Register(app *backpack.App) error {
## Dependencies
-- `golem15.user` (`git.golem15.com/golem15/sm-user-plugin` v0.1.5 or newer): users, identities, tokens, the user payload, the provider list endpoint, and `users.email_verified_at`, the owner's proof of the address that account matching reads.
+- `golem15.user` (`git.golem15.com/golem15/sm-user-plugin`, a version with the login gate registry, `CheckLoginGates` and `LoginSourceSocial`): users, identities, tokens, the user payload, the provider list endpoint, the login gates, and `users.email_verified_at`, the owner's proof of the address that account matching reads.
- `github.com/markbates/goth` v1.82.0: the core package and the `google`, `facebook`, `github` and `azureadv2` provider packages only (never its session helper package, so `gorilla/sessions` is not linked); only `classes/providers_goth.go` imports it.
- `golang.org/x/oauth2` (token exchange errors, and the Microsoft code exchange that keeps the id_token).
- SummerCMS v0.3.1 or newer (`git.golem15.com/golem15/summercms`; `cooler.Cache.Pull` consumes the one-time codes), modules `backpack`, `bouncer`, `cabana`, `cooler`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`.
@@ -195,7 +205,7 @@ go test -short ./... # no Docker
go test ./... # also the testcontainers Postgres smoke tests
```
-The smoke tests: `TestTracerSmoke` drives a returning user's sign-in through a fake provider registered in the plugin's registry; `TestFlowSmoke` covers email matches, account creation at the callback and on the pending screen, and the token cookie; `TestLinkSmoke` covers the link ticket, bearer linking and the start-request errors; `TestSettingsOverrideSmoke` covers the admin settings override; `TestGothProvidersSmoke` drives the four goth wrappers against fake provider APIs.
+The smoke tests: `TestTracerSmoke` drives a returning user's sign-in through a fake provider registered in the plugin's registry; `TestFlowSmoke` covers email matches, account creation at the callback and on the pending screen, and the token cookie; `TestLinkSmoke` covers the link ticket, bearer linking and the start-request errors; `TestSocialGateSmoke` adds a fake login gate to golem15.user's registry and covers the unchanged sign-in without a challenge, the `two_factor_required` body from `oauth-complete` and `oauth-register-complete` with no token and the deactivated account left deactivated, the refusal redirect with `two_factor_unavailable`, a gate error failing closed and an ungated link; `TestSettingsOverrideSmoke` covers the admin settings override; `TestGothProvidersSmoke` drives the four goth wrappers against fake provider APIs.
The unit tests, one per decision. Without Docker (`-short`): `TestRegistry` (names, reserved names, order, Build), `TestNormalizeFrontendCallback` and `TestNormalizeReturnTo` (the callback and return_to tables), `TestCodes` (64-character codes, sealed cache values, TTLs, single use), `TestEmailVerified` (per-provider email-verified rules), `TestGothProviders` (authorization URLs, sign-ins, the Microsoft tenant from the id_token and its refusals: no id_token, malformed, foreign `aud`, non-GUID `tid`; expired codes without response bodies, timeouts, no request outside the provider's hosts against fake APIs) and `TestErrorMessages` (every message in en and pl, the PHP texts byte for byte). On Postgres: `TestStateCookie` (sealing, refusals, attributes, cleared on every callback outcome), `TestRoutes`, `TestRouteMiddleware` (throttles from the route table, the 21st request answers 429), `TestProvidersList`, `TestMatching`, `TestNOAuth` (a Microsoft mail matching an existing user never links, in any tenant), `TestSoftDeleted`, `TestInactiveAndBanned` (including a banned and deactivated account, refused on every path, never restored and never mailed), `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestLink`, `TestLinkTicket`, `TestOAuthComplete`, `TestTokenCookieMode`, `TestRegisterComplete` (including 16 concurrent submissions of one code: one 200, fifteen 410), `TestPreAccountTakeover` (a verified sign-in never links onto a password-less account), `TestTenantProfileData` (`profile_data.tenant_id` only with a tenant, kept through a pending sign-in), `TestSignedInEvent` (one event per login, register, link, pending completion and restoring login with every field; failing sign-in, register and created listeners are logged and fail nothing; a listener's write reaches the payload) and `TestSettings` (overrides, write-only secrets, no secret in any log line, the PHP environment names).
diff --git a/classes/codes.go b/classes/codes.go
index b2dbb5b..bf5f765 100644
--- a/classes/codes.go
+++ b/classes/codes.go
@@ -31,12 +31,15 @@ const (
)
// CompletionPayload is what GET /_user/api/v1/oauth-complete hands over
-// for a code.
+// for a code. Challenge is set (and Token and User are empty) when a login
+// gate held the sign-in back for a second factor; oauth-complete then
+// answers exactly that body.
type CompletionPayload struct {
- Token string `json:"token"`
- User map[string]any `json:"user"`
- Action string `json:"action"`
- ReturnTo string `json:"return_to"`
+ Token string `json:"token"`
+ User map[string]any `json:"user"`
+ Action string `json:"action"`
+ ReturnTo string `json:"return_to"`
+ Challenge map[string]any `json:"challenge,omitempty"`
}
// PendingPayload is a sign-in that ended on the frontend's pending
diff --git a/classes/flow.go b/classes/flow.go
index 452eb7e..2cd42fc 100644
--- a/classes/flow.go
+++ b/classes/flow.go
@@ -6,6 +6,7 @@ import (
"net/http"
"time"
+ userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usercontrollers "git.golem15.com/golem15/sm-user-plugin/controllers"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -50,6 +51,12 @@ type Outcome struct {
Action string
ReturnTo string
Pending *PendingPayload
+ // Challenge is set instead of Token and User when a login gate (a
+ // two-factor plugin) held the sign-in back: the body the frontend gets
+ // from oauth-complete or oauth-register-complete, for example
+ // {two_factor_required, challenge_token, available_methods,
+ // expires_at}.
+ Challenge map[string]any
}
// HandleCallback runs the account matching of the PHP SocialAuth component
@@ -321,12 +328,35 @@ func loginWithIdentity(ctx context.Context, app *backpack.App, r *http.Request,
// signIn runs the steps of a password login after authentication, fires
// ev (SignedInEvent) and returns the login outcome with a fresh session
// token for the user row read again after the listeners.
+//
+// First it consults golem15.user's login gates with source social (D-09),
+// before FinishLogin, so a deactivated account is not restored and no token
+// exists before a second factor. A gate that answers 200 (a two-factor
+// challenge) makes the outcome carry that body as Challenge instead of a
+// token, and the sign-in stops there: FinishLogin, SignedInEvent and the
+// token are left to the second factor's completion. Any other status is a
+// refusal (two_factor_unavailable); a gate error is returned, so the
+// sign-in fails closed. A link (linkIdentity) never comes here.
func signIn(ctx context.Context, app *backpack.App, r *http.Request, db *gorm.DB, user *usermodels.User, ev *SignedInEvent, returnTo string) (Outcome, error) {
+ gate, err := usercontrollers.CheckLoginGates(ctx, app, userclasses.LoginAttempt{User: user, Source: userclasses.LoginSourceSocial, Request: r})
+ if err != nil {
+ return Outcome{}, err
+ }
+ if gate != nil {
+ if gate.Status != 0 && gate.Status != http.StatusOK {
+ return Outcome{}, flowErr("two_factor_unavailable")
+ }
+ body := gate.Body
+ if body == nil {
+ body = map[string]any{}
+ }
+ return Outcome{Kind: OutcomeLogin, Challenge: body, Action: ev.Action, ReturnTo: returnTo}, nil
+ }
if err := usercontrollers.FinishLogin(ctx, app, db, user, ClientIP(app, r)); err != nil {
return Outcome{}, err
}
action := ev.Action
- user, err := fireSignedIn(ctx, app, db, ev)
+ user, err = fireSignedIn(ctx, app, db, ev)
if err != nil {
return Outcome{}, err
}
diff --git a/controllers/callback.go b/controllers/callback.go
index 956d791..3f3b8de 100644
--- a/controllers/callback.go
+++ b/controllers/callback.go
@@ -111,10 +111,11 @@ func finishCallback(w http.ResponseWriter, r *http.Request, app *backpack.App, c
return
}
code, err := classes.PutCompletion(r.Context(), app, classes.CompletionPayload{
- Token: out.Token,
- User: out.User,
- Action: out.Action,
- ReturnTo: out.ReturnTo,
+ Token: out.Token,
+ User: out.User,
+ Action: out.Action,
+ ReturnTo: out.ReturnTo,
+ Challenge: out.Challenge,
})
if err != nil {
slog.Default().Error("golem15.sociallogin: store completion code", "err", err)
diff --git a/controllers/complete.go b/controllers/complete.go
index de2a7ba..481082f 100644
--- a/controllers/complete.go
+++ b/controllers/complete.go
@@ -13,7 +13,10 @@ import (
// return_to}, 422 for an empty code and 410 for a missing, used or expired
// one, as the PHP API does. With golem15.sociallogin.token_cookie the
// token is set as golem15.user's token cookie (bouncer TokenCookie) and
-// left out of the body.
+// left out of the body. When a login gate held the sign-in back (D-09), it
+// answers 200 with exactly the gate's body, for example
+// {two_factor_required, challenge_token, available_methods, expires_at},
+// and no token or cookie.
func Complete(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
@@ -31,6 +34,10 @@ func Complete(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusGone, map[string]any{"error": "OAuth completion code is invalid or expired"})
return
}
+ if p.Challenge != nil {
+ writeJSON(w, http.StatusOK, p.Challenge)
+ return
+ }
writeSession(w, app, p.Token, p.User, p.Action, classes.ActionLogin, p.ReturnTo)
}
}
diff --git a/controllers/register_complete.go b/controllers/register_complete.go
index 647775f..48d6254 100644
--- a/controllers/register_complete.go
+++ b/controllers/register_complete.go
@@ -21,6 +21,8 @@ import (
// only inside the final transaction, and a request that loses that race
// answers 410. Responses follow the PHP API: 200 {token, user, action
// (register default), return_to}, 422 {error[, errors]}, 410 {error}.
+// When the sign-in of an existing account is held back by a login gate
+// (D-09), it answers 200 with exactly the gate's body instead.
func RegisterComplete(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fields, err := readFields(r)
@@ -107,6 +109,10 @@ func RegisterComplete(app *backpack.App) http.HandlerFunc {
}
return
}
+ if out.Challenge != nil {
+ writeJSON(w, http.StatusOK, out.Challenge)
+ return
+ }
writeSession(w, app, out.Token, out.User, out.Action, classes.ActionRegister, out.ReturnTo)
}
}
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index 8159c5e..2db3d73 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -25,6 +25,7 @@ oauth:
provider_not_configured: ":provider login is not configured. Please contact the administrator."
invalid_action: "Invalid OAuth action specified."
must_be_logged_in: "You must be logged in to link an OAuth account."
+ two_factor_unavailable: "Two-factor verification is temporarily unavailable. Try again later."
register:
consent_required: "You must accept the terms and the privacy policy to complete the registration."
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index 803e5f2..7f336e2 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -25,6 +25,7 @@ oauth:
provider_not_configured: "Logowanie przez :provider nie jest skonfigurowane. Skontaktuj się z administratorem."
invalid_action: "Wskazano nieprawidłową akcję logowania."
must_be_logged_in: "Musisz być zalogowany, aby połączyć konto dostawcy logowania."
+ two_factor_unavailable: "Weryfikacja dwuetapowa jest chwilowo niedostępna. Spróbuj ponownie później."
register:
consent_required: "Musisz zaakceptować regulamin i politykę prywatności, aby dokończyć rejestrację."
diff --git a/social_gate_smoke_test.go b/social_gate_smoke_test.go
new file mode 100644
index 0000000..4dd5e3e
--- /dev/null
+++ b/social_gate_smoke_test.go
@@ -0,0 +1,191 @@
+package sociallogin_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+
+ "git.golem15.com/golem15/sm-sociallogin-plugin/classes"
+ userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
+ usermodels "git.golem15.com/golem15/sm-user-plugin/models"
+)
+
+// fakeGate is a login gate a test adds to golem15.user's published
+// registry, standing in for a two-factor plugin. It answers result (or
+// err) for the users in targets and allows everyone else, and records the
+// attempts it saw.
+type fakeGate struct {
+ mu sync.Mutex
+ targets map[uint]bool
+ result *userclasses.LoginGateResult
+ err error
+ attempts []userclasses.LoginAttempt
+}
+
+func (g *fakeGate) CheckLogin(_ context.Context, a userclasses.LoginAttempt) (*userclasses.LoginGateResult, error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.attempts = append(g.attempts, a)
+ if a.User == nil || !g.targets[a.User.ID] {
+ return nil, nil
+ }
+ return g.result, g.err
+}
+
+// set makes the gate answer result or err for user.
+func (g *fakeGate) set(user *usermodels.User, result *userclasses.LoginGateResult, err error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.targets = map[uint]bool{user.ID: true}
+ g.result, g.err = result, err
+ g.attempts = nil
+}
+
+// calls returns the attempts seen since the last set.
+func (g *fakeGate) calls() []userclasses.LoginAttempt {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return append([]userclasses.LoginAttempt(nil), g.attempts...)
+}
+
+// TestSocialGateSmoke covers D-09: a social sign-in consults golem15.user's
+// login gates with source social before anything signs the user in.
+// Without a challenge it answers as before; a challenge replaces the token
+// in oauth-complete and oauth-register-complete with the gate's body and
+// leaves a deactivated account deactivated; a refusal redirects with the
+// two_factor_unavailable message; a gate error fails closed; linking is
+// never gated.
+func TestSocialGateSmoke(t *testing.T) {
+ e := bootSocial(t, nil)
+ idp := registerFake(t, e.registry(t), "fake", "Fake")
+ e.credentials(t, "fake")
+ gates, ok := e.app.Lookup[*userclasses.LoginGates]()
+ if !ok || gates == nil {
+ t.Fatal("golem15.user published no login gate registry")
+ }
+ gate := &fakeGate{}
+ gates.Add(gate)
+
+ challenge := map[string]any{
+ "two_factor_required": true,
+ "challenge_token": strings.Repeat("c", 64),
+ "available_methods": []any{"totp"},
+ "expires_at": "2026-10-26T12:05:00+00:00",
+ }
+ const challengeJSON = `{"available_methods":["totp"],"challenge_token":"` +
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +
+ `","expires_at":"2026-10-26T12:05:00+00:00","two_factor_required":true}`
+
+ t.Run("no_gate_unchanged", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-1")
+ gate.set(&usermodels.User{}, nil, nil)
+ idp.on("g1", classes.Profile{ID: "fake-gate-1", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g1", nil)
+ res, got := e.complete(t, done.Query().Get("code"))
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("complete: %d %v", res.StatusCode, got)
+ }
+ if tok, _ := got["token"].(string); tok == "" || got["user"] == nil || got["action"] != "login" || got["return_to"] != "/dashboard" {
+ t.Fatalf("complete body %v", got)
+ }
+ if _, ok := got["challenge"]; ok {
+ t.Fatalf("challenge key in a plain sign-in: %v", got)
+ }
+ calls := gate.calls()
+ if len(calls) != 1 || calls[0].Source != userclasses.LoginSourceSocial || calls[0].User.ID != member.ID || calls[0].Request == nil || calls[0].Fields != nil {
+ t.Fatalf("gate attempts %+v", calls)
+ }
+ })
+
+ t.Run("challenge_body", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-2")
+ if err := e.gdb.Delete(member).Error; err != nil {
+ t.Fatal(err)
+ }
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusOK, Body: challenge}, nil)
+ idp.on("g2", classes.Profile{ID: "fake-gate-2", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g2", nil)
+ code := done.Query().Get("code")
+ if code == "" || done.Query().Get("error") != "" {
+ t.Fatalf("callback redirect %s", done)
+ }
+ res, body := e.do(t, e.browser(t), http.MethodGet, "/_user/api/v1/oauth-complete?code="+url.QueryEscape(code), nil, nil)
+ if res.StatusCode != http.StatusOK || body != challengeJSON {
+ t.Fatalf("oauth-complete: %d %s", res.StatusCode, body)
+ }
+ for _, c := range res.Cookies() {
+ if c.Value != "" {
+ t.Fatalf("cookie %s set with a challenge", c.Name)
+ }
+ }
+ var back usermodels.User
+ if err := e.gdb.Unscoped().Where("id = ?", member.ID).Take(&back).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !back.DeletedAt.Valid {
+ t.Fatal("a challenged social sign-in restored the deactivated account")
+ }
+
+ // oauth-register-complete of a pending registration whose identity
+ // now belongs to the account answers the challenge too.
+ pending, err := classes.PutPending(t.Context(), e.app, classes.PendingPayload{Provider: "fake", ProviderID: "fake-gate-2", ReturnTo: "/welcome"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, body = e.do(t, e.browser(t), http.MethodPost, "/_user/api/v1/oauth-register-complete",
+ strings.NewReader(`{"pending_code":"`+pending+`","terms_accepted":true,"privacy_accepted":true}`),
+ http.Header{"Content-Type": {"application/json"}})
+ if res.StatusCode != http.StatusOK || body != challengeJSON {
+ t.Fatalf("oauth-register-complete: %d %s", res.StatusCode, body)
+ }
+ })
+
+ t.Run("refusal_redirects", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-3")
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil)
+ idp.on("g3", classes.Profile{ID: "fake-gate-3", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g3", nil)
+ if done.Query().Get("code") != "" || done.Query().Get("error") != "Two-factor verification is temporarily unavailable. Try again later." {
+ t.Fatalf("refusal redirect %s", done)
+ }
+ })
+
+ t.Run("gate_error_fails_closed", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-4")
+ gate.set(member, nil, errors.New("gate store down"))
+ idp.on("g4", classes.Profile{ID: "fake-gate-4", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g4", nil)
+ if done.Query().Get("code") != "" || done.Query().Get("error") == "" || strings.Contains(done.RawQuery, "store") {
+ t.Fatalf("gate error redirect %s", done)
+ }
+ if len(gate.calls()) != 1 {
+ t.Fatalf("gate attempts %d", len(gate.calls()))
+ }
+ })
+
+ t.Run("link_not_gated", func(t *testing.T) {
+ member := e.user(t, "")
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusOK, Body: challenge}, nil)
+ ticket := e.ticketFor(t, http.Header{"Authorization": {"Bearer " + e.sessionToken(t, member)}})
+ idp.on("g5", classes.Profile{ID: "fake-gate-5", Email: "other-" + member.Email})
+ done := e.signIn(t, e.browser(t), "fake", linkQuery(ticket), "g5", nil)
+ res, got := e.complete(t, done.Query().Get("code"))
+ if res.StatusCode != http.StatusOK || got["action"] != "link" {
+ t.Fatalf("link: %d %v (redirect %s)", res.StatusCode, got, done)
+ }
+ if tok, _ := got["token"].(string); tok == "" {
+ t.Fatalf("link without token: %v", got)
+ }
+ if n := len(gate.calls()); n != 0 {
+ t.Fatalf("link consulted the gate %d times", n)
+ }
+ })
+}
--
2.56.0

View File

@@ -0,0 +1,8 @@
## Deferred Items
- sm-sociallogin-plugin: `signin_event_test.go` is not gofmt-clean at origin/master `4d3b2dd` (v0.1.3)
status: open
**What:** `gofmt -l .` lists it: `logRecorder.Enabled` is a one-line method gofmt now splits. It came with `4d3b2dd` from another session. Plan 19.6-03 did not touch it. Fix it in a `style:` commit in sm-sociallogin-plugin.
- vue-demo-app has no type-check tooling
status: open
**What:** the Nuxt build does not type-check, and `vue-tsc` is not a dependency, so 19.6-03 Task 3 checked the new page, composables and header with vitest and `pnpm build` only. The user decides whether to add `nuxi typecheck`.