docs(19.5): security review and validation map
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# Phase 19.5 Security Review: social sign-in (sm-sociallogin-plugin)
|
||||
|
||||
**Date:** 2026-10-21
|
||||
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain"), block on high; D-11 asks for this review of the matching and state code.
|
||||
**Scope:** `cooler.Cache.Pull` (summercms.go `modules/cooler`), this phase's sm-user-plugin additions (provider source, row-locked unlink, exported sign-in helpers), the new `sm-sociallogin-plugin` (registry, goth wrappers, state cookie, callback rules, one-time codes, matching, registration, linking, settings), the adoption in sm-demo-app, sm-bm-app and fonoteka.go, and this plan's gate.
|
||||
**Threats reviewed:** T-19.5-01..T-19.5-37 and T-19.5-SC from the threat models of plans 01, 02, 03 and 04, plus RESEARCH's Security Domain table.
|
||||
**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason (process controls). Two defects found by the new tests were fixed in their own commits (one of them on T-19.5-12). One new finding, T-19.5-38 (pre-account takeover through an account created from an unverified provider email, the PHP behaviour D-10 ported), is **open: it needs a decision** and is raised with the plan's checkpoint.
|
||||
|
||||
Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **S** = `sm-sociallogin-plugin`, **D** = sm-demo-app, **B** = sm-bm-app (with `plugins/jz/bm` and `frontend/`), **A** = the fonoteka.go workspace.
|
||||
|
||||
## Method
|
||||
|
||||
1. For each threat, read the code that implements the mitigation (file and line below) and find the test that fails without it. Where a test was missing, add it as a separate commit in the owning repository.
|
||||
2. Mutation checks on the race-sensitive mitigations: making `memoryCache.Pull` a Get followed by a delete fails `TestPullConcurrent/memory`; removing `FOR UPDATE` from `OAuthIdentitiesDestroy` fails `TestOAuthIdentitiesDestroyRace` (both handler variants and the concurrent handlers); six edits of `PHP_METHOD_MAP.md` (missing, extra and unknown rows, a wrong subtest, a wrong repository, a missing reason) each fail `TestPHPMethodMap`. Every mutation was reverted.
|
||||
3. Sweeps: every non-test `slog` call (section "Log calls"), every response that carries a credential, the provider HTTP clients, the URL of every redirect.
|
||||
4. Run the whole gate: `scripts/check-phase19.5.sh --all` on a Docker host.
|
||||
|
||||
## Defects fixed during this plan
|
||||
|
||||
| Commit | Repo | Threat | Found by | Fix |
|
||||
|--------|------|--------|----------|-----|
|
||||
| 21f3fe5 | S | T-19.5-12 | `TestRegisterComplete/concurrent_submit_one_winner` | Two submissions of one pending code that both reached account creation raced on the `users.email` unique index: the loser answered **500** instead of 410. Account creation now takes transaction-scoped advisory locks on the provider identity and the email (`lockCreation`), and a completion that gets them after a winner committed finds the identity and returns `ErrPendingConsumed` (410), writing nothing. |
|
||||
| c99f5f9 | S | T-19.5-16 (settings write path) | `TestSettings/admin_json_hides_secrets` | Every save of the admin settings page answered 500: `Settings.Rules()` used `regex:`, which lagoon's model validation does not support, so the encrypted secrets could not be managed from the admin at all. The tenant format moved to `Settings.BeforeValidate` (422 on `microsoft_tenant`); README updated. |
|
||||
|
||||
Both are production changes after plan 02's release (v0.1.0); they reach the applications only through the release the plan's checkpoint asks for (T-19.5-36).
|
||||
|
||||
## Test pins added by the review
|
||||
|
||||
| Commit | Repo | Threats | What the test pins |
|
||||
|--------|------|---------|--------------------|
|
||||
| b7fa407 | S | T-19.5-08, T-19.5-12 | `TestStateCookie/key_domain_separated` and `TestCodes/key_domain_separated`: a state cookie opens only under HKDF(app.key, `golem15.sociallogin.oauth-state.v1`), a code value only under HKDF(app.key, `golem15.sociallogin.code.v1`); neither opens with the raw application key, lagoon's column key (`summercms.lagoon.encrypted.v1`) or the other purpose's key. |
|
||||
|
||||
The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register names them.
|
||||
|
||||
## Threat register
|
||||
|
||||
| Threat | Component | Mitigation as built | Implementation | Test (repo) | Status |
|
||||
|--------|-----------|---------------------|----------------|-------------|--------|
|
||||
| T-19.5-01 | `cooler.Cache.Pull` | Redis `GETDEL` and otter `Compute` with `InvalidateOp`: get and delete are one step; an expired entry is removed and reported not found. | F `modules/cooler/rediscache.go:57`, `memory.go:90` | `TestPullConcurrent` (64 pullers, 20 rounds, memory and miniredis), `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (redis:7-alpine) (F) | closed (mutation-checked) |
|
||||
| T-19.5-02 | `OAuthIdentitiesDestroy` (DoS: zero sign-in methods) | `SELECT id FROM users WHERE id = ? FOR UPDATE` before the guarded DELETE, in one transaction. | U `controllers/oauth_identities.go:101-111` | `TestOAuthIdentitiesDestroyRace` (two transactions in both orders, the handler waiting for the lock, concurrent handlers), `TestOAuthIdentitiesDestroyConcurrentSmoke` (U) | closed (mutation-checked) |
|
||||
| T-19.5-03 | unlink rule relaxation (D-13) | The DELETE is scoped to the JWT principal's user id and the path provider; it succeeds only with a self-set password or another identity; 409 and 404 bodies unchanged. | U `controllers/oauth_identities.go:32-35`, `:71-124` | `TestOAuthIdentitiesDestroyRule` (table incl. another user's identity 404, EN and PL 409 bodies) (U) | closed |
|
||||
| T-19.5-04 | sm-user-plugin core contract | Additive exports only; no route, payload, table, migration or config key changed. | U commits 78a867b, 1741618, c43ba87 | gate `--userplugin`: no exported declaration removed and no file under `updates/` changed since 4c5d3b8; `--fonoteka` green (F) | closed |
|
||||
| T-19.5-05 | `FinishLogin` restoring a soft-deleted account | FinishLogin authenticates nobody; S calls it only after an identity or a verified-email match (`signIn`), and the reactivate mail tells the owner. | U `controllers/registration.go:99`; S `classes/flow.go:269-305` | `TestFinishLogin/trashed_user_restored_with_one_mail` (U); `TestSoftDeleted/unverified_match_pending_and_no_second_row` (an unverified match never restores) (S) | closed |
|
||||
| T-19.5-06 | logs of the user-plugin additions | The one new log call logs `user_id` and a database error. | U `controllers/registration.go:121` | section "Log calls" | accepted: a single fixed-shape log line with no token, secret or code; nothing to pin beyond the review |
|
||||
| T-19.5-07 | callback (login CSRF, injected code) | Sealed per-browser state cookie; empty state refused; `subtle.ConstantTimeCompare`; provider bound by the cookie name (AAD) and the `Provider` field; cookie cleared on every outcome. | S `controllers/callback.go:27`, `:35-45`; `classes/statecookie.go:134-162` | `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed exchange, expired code) (S) | closed |
|
||||
| T-19.5-08 | `SealState` / `OpenState` | AES-256-GCM, fresh nonce, HKDF purpose key separate from lagoon's, AAD the cookie name, version and iat (600 s, 60 s skew) checked, 3800-byte bound. | S `classes/statecookie.go:93-129`, `:180-220` | `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed |
|
||||
| T-19.5-09 | `NormalizeFrontendCallback`, `NormalizeReturnTo` | Exact-host allow-list, fixed path, the target rebuilt from scheme, host and port; return_to local only; invalid input falls back to `default_callback`. The callback uses the target sealed at the start, never a request value. | S `classes/callback.go:56-107`; `controllers/callback.go:36-39` | `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed |
|
||||
| T-19.5-10 | email matching (account takeover) | Verified only where the provider asserts it: Google a boolean `verified_email`/`email_verified`, GitHub the primary verified `/user/emails` address, Facebook and Microsoft never; an unverified match goes pending `unverified_match`; case-insensitive match. | S `classes/providers_goth.go:126-254` (Facebook `:154`, Microsoft `:251`); `classes/flow.go:92-101` | `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuthGate` (S) | closed (see T-19.5-38 for the creation path) |
|
||||
| T-19.5-11 | `RegisterComplete` (pending hijack) | Completion re-checks identity and email match; `email_verified` false or absent gives 422 `email_exists_link_account`, never a login; payloads sealed. | S `classes/flow.go:175-228` | `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed |
|
||||
| T-19.5-12 | one-time codes (replay, race) | 64-character `crypto/rand` codes sealed under the cache key, consumed with `Pull`; TTLs 5 min, 10 min, 60 s; register-complete decides before it pulls; concurrent completions are serialised (21f3fe5). | S `classes/codes.go:81-224`; `controllers/register_complete.go:80-87`; `classes/registration.go:192` | `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | fixed in commit 21f3fe5 (with its test) |
|
||||
| T-19.5-13 | beta gate (D-12) | Constant-time comparison on both creation paths; the start request's key carried in the sealed cookie and pending payload. | S `classes/registration.go:55-67`; `controllers/register_complete.go:71`, `:116-125` | `TestBetaGate` (S) | closed |
|
||||
| T-19.5-14 | `action=link`, link ticket | Link user from `RequestUser` (blacklist, `tokens_valid_after`, soft delete) or a single-use 60 s ticket issued to an authenticated POST; the user id sealed in the cookie; another user's identity refused; the callback re-checks the user. | S `controllers/redirect.go:93-106`; `controllers/link_ticket.go:19-42`; `classes/flow.go:127-163` | `TestLink` (bearer, both cookies, blacklisted token, `tokens_valid_after`, trashed at start and at callback, token in query ignored), `TestLinkTicket`, `TestPHPOAuthLinkCookie` (S) | closed |
|
||||
| T-19.5-15 | URLs and logs | No JWT in a URL (ticket, one-time code); the access log records no path or query; no log line carries a token, secret, code, ticket or provider error body. | S controllers (section "Log calls"); F `modules/surf/accesslog.go:42-49`; S `classes/providers_goth.go:85-99` | `TestLinkTicket/no_jwt_in_locations`, `TestSettings/no_secret_in_logs` (a provider whose errors carry the secret and a token), `TestGothProviders/expired_code_without_body`, `other_errors_without_body` (S); `TestAccessLogSmoke` (F) | closed |
|
||||
| T-19.5-16 | client secrets and provider tokens at rest | Secrets in `lagoon.Encrypted` write-only columns, never in the admin JSON; provider tokens in the identity's encrypted columns; cache values sealed. The settings page saves again (c99f5f9). | S `models/settings.go:35-48`, `:84-92`; `classes/flow.go:367-404` | `TestSettings/admin_json_hides_secrets` (stored, env and empty states; a saved secret is ciphertext), `TestIdentityTokens/encrypted_tokens`, `TestCodes/completion_sealed_and_consumed_once` (S) | fixed in commit c99f5f9 (the write path; confidentiality held) |
|
||||
| T-19.5-17 | provider calls and routes (DoS) | Every provider client has a timeout (`http_timeout`, default 10 s; a fallback client too); `throttle:20,1` on /oauth, `throttle:user-api` on the API routes. | S `classes/credentials.go:32`; `classes/providers_goth.go:116-121`; `routes.go:13-23` | `TestGothProviders/timeout` (all four wrappers against a stalled transport, the configured timeout), `TestRouteMiddleware` (route table, 21st request 429) (S) | closed |
|
||||
| T-19.5-18 | route shadowing with the OIDC provider | Reserved names refused by the registry; unknown names answer 404 without a cookie. | S `classes/provider.go:76`, `:122-149`; `controllers/redirect.go:30-34` | `TestRegistry/reserved_names`, `TestRoutes/unknown_and_reserved_404` (S); `TestSocialLoginCoexistsWithOAuthServer` (D) | closed |
|
||||
| T-19.5-19 | inactive or banned users | `checkCanSignIn` (activation, `user_throttle.is_banned`) on identity, verified-match, link and pending paths; restore only on identity or verified match. | S `classes/flow.go:340-355`, `:127-163`, `:269-290` | `TestInactiveAndBanned` (identity, verified match, link, pending completion), `TestSoftDeleted` (S) | closed |
|
||||
| T-19.5-20 | account creation flood | `allow_registration` and the per-IP register throttle (3 an hour) on both creation paths. | S `classes/registration.go:71-80` | `TestRegistrationGates` (S) | closed |
|
||||
| T-19.5-21 | consent records (repudiation) | Consent flags, policy versions, IP, user agent, provider and `password_bootstrap_source` in the register event's payload; `marketing_consent` column on opt-in; no password. | S `classes/registration.go:151-171` | `TestIdentityTokens/register_event_payload`, `TestRegisterComplete/form_encoded` (S) | closed |
|
||||
| T-19.5-22 | stale consent | Callback consent at most 600 s old. | S `classes/registration.go:85-96` | `TestConsent/expired_after_600s` (forged 601 s and 590 s cookies) (S) | closed |
|
||||
| T-19.5-23 | remote operations of plan 02 | Blocking go-ahead, fetch and range check before every push, never forced, no deploy. | 19.5-02-SUMMARY "Remote operations" | release control | accepted: a process control, recorded in 19.5-02-SUMMARY |
|
||||
| T-19.5-24 | bm SPA fallback | `/oauth` among the reserved prefixes of the static handler and the service worker. | B `plugins/jz/bm/static.go`; `frontend/src/pwa/sw.js` | `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed |
|
||||
| T-19.5-25 | committed configuration and env templates | Credentials only in `.env` or the encrypted settings. | D, B `config/`, `deploy/` | grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | accepted: verified by grep; no runtime behaviour to test |
|
||||
| T-19.5-26 | bm callback view (code in history) | The view replaces the route before redeeming the code once; the server consumes it with `Pull`, so a code left in history is dead. | B `frontend/src/views/OAuthCallbackView.vue`; S `controllers/complete.go:24` | `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B) | closed for the server half; the view's history handling is a browser behaviour left to `/msd-verify-work` (manual row in VALIDATION.md) |
|
||||
| T-19.5-27 | Microsoft on bm (nOAuth, tenant) | Microsoft never verified, whatever the tenant; existing students link through the ticket. | S `classes/providers_goth.go:225-254` | `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (creation path: T-19.5-38) |
|
||||
| T-19.5-28 | link ticket in the bm profile flow | Ticket from an authenticated POST, 60 s, single use, the only value in the URL. | S `controllers/link_ticket.go`; B `frontend/src/lib/oauth.ts` | `TestLinkTicket` (S); `oauth.test.ts` (B) | closed |
|
||||
| T-19.5-29 | pushes of shared repositories (plan 03) | Push only under plan 02's reply, plugin before pointer, after fetch and a range check. | 19.5-03-SUMMARY "Pushes" | release control | accepted: a process control, recorded in 19.5-03-SUMMARY |
|
||||
| T-19.5-30 | fonoteka regression from D-13 | fonoteka's vet and short suite at the new pointer; the Docker-backed user plugin suite. | A | gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) |
|
||||
| T-19.5-31 | gate false green | go test -json detector fails on fail, skip (subtests too), zero tests, "no tests to run", a missing name, a prefix or a subtest standing in for a name; the vitest detector refuses skips and a missing `oauth.test.ts`; NOT RUN docker is loud and fails with `PHASE195_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.5.sh` | `scripts/check-phase19.5.sh --self-test` (F) | closed |
|
||||
| T-19.5-32 | race tests (D-13, D-08) | Forced with explicit transactions and start barriers, both orders, repeated. | F `modules/cooler/pull_test.go`; U `oauth_identities_rule_test.go` | the mutation checks above (F, U) | closed |
|
||||
| T-19.5-33 | completeness of this review | Every T-19.5 ID of plans 01-04 appears here with a test or an accepted reason. | this document | the plan's acceptance count | closed |
|
||||
| T-19.5-34 | test fixtures and logs | Test-only literal secrets; provider HTTP faked through `ProviderConfig.HTTPClient`; the fake transport refuses hosts outside its table and the tests check every request stayed on the provider's hosts. | S `providers_goth_test.go`, `settings_test.go` | `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed |
|
||||
| T-19.5-35 | PHP map honesty | The map is checked against the list, the live PHP files and the Go test sources of S and U. | S `php_method_map_test.go` | `TestPHPMethodMap` (mutation-checked) (S) | closed |
|
||||
| T-19.5-36 | post-release fixes | The plan's "Post-release fixes" table lists 21f3fe5 and c99f5f9 against every pin that lacks them; the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order and checks every pin with `merge-base --is-ancestor`. | 19.5-04 Tasks 5-7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria (open until the Task 6 reply) |
|
||||
| T-19.5-37 | remote operations of Task 7 | Only after the Task 6 reply; fetch and range check before every push; never forced or deployed; only `plugins/golem15/user` staged in fonoteka.go. | 19.5-04 Task 7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria |
|
||||
| T-19.5-SC | module installs | goth v1.82.0 approved in plan 01 Task 4; no module installed in plan 04 (S `go.mod` and `go.sum` unchanged since adfcfbf); goth imported only by `classes/providers_goth.go`; no gorilla/sessions or gopkg.in/yaml.v3 in `go list -deps .`. | S `go.mod`; `classes/providers_goth.go:13-18` | gate `--sociallogin` (deps and importer checks) (F) | closed |
|
||||
|
||||
## New finding
|
||||
|
||||
| Threat | Category | Component | Severity | Description | Status |
|
||||
|--------|----------|-----------|----------|-------------|--------|
|
||||
| T-19.5-38 | Elevation of privilege | account creation from an unverified provider email (`HandleCallback` with `action=register`, `CompletePending`) | high | An account is created **activated** with an email the provider did not verify (Facebook, Microsoft, a Google answer without the flag). An attacker who controls such a provider account with a victim's address (any Entra tenant can set `mail`; nOAuth) creates the account before the victim does. Later the victim signs in with a verifying provider (Google, GitHub): the verified match links that identity to the attacker's account, and the attacker keeps access through the original identity ("pre-account takeover", classic-federated merge). On a Microsoft-only site (bm) the victim cannot sign in through Microsoft (unverified match), resets the password through the mailbox and shares the account with the attacker. PHP's SocialAuth behaves the same (D-10 ports it), so this is not a regression; it is open because a fix changes D-10 behaviour. Options: (a) create accounts from an unverified email not activated and send golem15.user's activation mail (the owner proves the mailbox before any sign-in or link); (b) refuse the verified-match auto-link onto an account whose email was never verified by its owner (no self-set password, only unverified identities); (c) accept for applications whose providers all verify, documenting it in the README. | open: decision needed (raised at the Task 6 checkpoint; not fixed in this plan, since a fix changes D-10) |
|
||||
|
||||
## Log calls
|
||||
|
||||
Every non-test `slog` call of S and of this phase's U changes, with what it logs. None logs a token, secret, code, ticket, cookie value, request URL or provider response body; the `err` attributes carry configuration, cache or database errors whose texts the review read (pgx errors carry the SQLSTATE message without row values; `exchangeError` drops `RetrieveError` bodies; the provider's own errors are never logged).
|
||||
|
||||
| Call | Level | Attributes | What `err` can hold |
|
||||
|------|-------|-----------|---------------------|
|
||||
| S `controllers/callback.go:30` "config" | Error | err | `LoadConfig`: a malformed `callback_path`, `default_callback` or `http_timeout` (configuration values, no secrets) |
|
||||
| S `controllers/callback.go:52` "credentials" | Error | provider, err | `ResolveCredentials`: only `LoadConfig` errors (the settings overlay never fails the request) |
|
||||
| S `controllers/callback.go:59` "build provider" | Error | provider, err | a registry factory's error (built-ins never fail; an application's own provider controls its text) |
|
||||
| S `controllers/callback.go:69` "provider refused the sign-in" | Warn | provider | nothing else: the provider's error is not logged (`TestSettings/no_secret_in_logs`) |
|
||||
| S `controllers/callback.go:83` "callback" | Error | provider, err | database errors of the matching and linking (pgx/GORM, no values), `IssueToken` and `APIArray` errors |
|
||||
| S `controllers/callback.go:98` "store pending registration" | Error | err | cache or app-key errors (`putSealed`) |
|
||||
| S `controllers/callback.go:120` "store completion code" | Error | err | cache or app-key errors |
|
||||
| S `controllers/complete.go:26` "pull completion code" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/link_ticket.go:23` "link ticket user" | Error | err | `RequestUser` infrastructure errors (database, blacklist store) |
|
||||
| S `controllers/link_ticket.go:33` "store link ticket" | Error | err | cache or app-key errors |
|
||||
| S `controllers/register_complete.go:39` "read pending registration" | Error | err | cache errors, or the fixed "undecodable code payload" |
|
||||
| S `controllers/register_complete.go:67` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/register_complete.go:105` "complete pending registration" | Error | err | database errors of `CompletePending` (validation and flow errors are answered, not logged) |
|
||||
| S `controllers/redirect.go:37` "config" | Error | err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:53` "credentials" | Error | provider, err | `LoadConfig` (as above) |
|
||||
| S `controllers/redirect.go:75` "resolve link user" | Error | err | `PullLinkTicket` cache errors or `RequestUser` infrastructure errors |
|
||||
| S `controllers/redirect.go:113` "build provider" | Error | provider, err | a registry factory's error (as above) |
|
||||
| S `controllers/redirect.go:124` "begin" | Error | provider, err | a provider's `Begin` error (goth's `BeginAuth` has none for the built-ins) |
|
||||
| S `controllers/redirect.go:132` "seal state" | Error | provider, err | the fixed size error ("state cookie of N bytes exceeds 3800") or app-key errors |
|
||||
| S `controllers/helpers.go:160` "token cookie config" | Error | err | `bouncer.LoadCookieConfig`: an invalid `auth.cookie.*` value |
|
||||
| S `classes/credentials.go:76` "settings unavailable, using the configuration" | Debug | err | the settings query's database error |
|
||||
| U `controllers/registration.go:121` "stamp last_seen on login" | Warn | user_id, err | the `last_seen` update's database error |
|
||||
|
||||
## Checks the plan names
|
||||
|
||||
- State compare is constant time and refuses an empty state: S `controllers/callback.go:41-42`; `TestStateCookie/callback/empty_state`, `unequal_state`.
|
||||
- The cookie key is domain-separated from lagoon's: S `classes/statecookie.go:35`, `:180-197`; `TestStateCookie/key_domain_separated`.
|
||||
- The callback target is rebuilt, never echoed: S `classes/callback.go:74-81`; the callback reads the target sealed at the start (`controllers/callback.go:36-39`); `TestNormalizeFrontendCallback/userinfo_dropped`, `query_and_fragment_dropped`, `encoded_path_rebuilt`.
|
||||
- Every login path checks activation and ban: identity, verified match and pending completion through `loginWithIdentity`, link through `linkIdentity` (`classes/flow.go:138`, `:270`); `TestInactiveAndBanned`.
|
||||
- Microsoft and Facebook can never set `EmailVerified`: `classes/providers_goth.go:154`, `:251`; `TestEmailVerified`.
|
||||
- Register-complete decides before it pulls: every 422 path returns before `consume`, which runs last inside the final transaction (`classes/flow.go:183-188`, `classes/registration.go:140-145`); `TestRegisterComplete` (consent, beta, `email_exists_link_account` and validation 422s keep the code).
|
||||
- Link tickets are single use: `classes/codes.go:141-148`; `TestLinkTicket/links_once`.
|
||||
- No JWT, provider token, secret, code or `RetrieveError` body reaches a log line: section "Log calls"; `TestSettings/no_secret_in_logs`, `TestGothProviders/expired_code_without_body`.
|
||||
- The unlink lock is taken before the DELETE: U `controllers/oauth_identities.go:102`; `TestOAuthIdentitiesDestroyRace` (mutation-checked).
|
||||
- goth has a timeout on every provider: `classes/credentials.go:32`, `classes/providers_goth.go:116-121`; `TestGothProviders/timeout`.
|
||||
|
||||
## Accepted residuals (low)
|
||||
|
||||
- Without `app.url`, the redirect URI sent to the provider is completed from the request's `Host` and `X-Forwarded-Proto` (`controllers/helpers.go:50-67`); a client can only change its own flow, and the provider refuses an unregistered redirect URI. Applications set `app.url`.
|
||||
- A registry factory or `Begin` error of an application-provided provider is logged as its author wrote it; the built-in wrappers never put a secret in an error.
|
||||
- `oauth-register-complete` ignores a `parental_authority` sent as false, as PHP's completion did (the callback path refuses it); the value is recorded in the register event's payload.
|
||||
@@ -3,10 +3,11 @@ phase: "19.5"
|
||||
slug: "social-login-in-sm-user-plugin"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: validated
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-19"
|
||||
validated: "2026-10-21"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Validation Strategy
|
||||
@@ -20,10 +21,10 @@ created: "2026-10-19"
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (skips under `-short`); miniredis and `redis:7-alpine` for cooler; vitest for the bm frontend |
|
||||
| **Config file** | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go`, copied from `sm-oauthserver-plugin` |
|
||||
| **Config file** | none (Go). `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go` (`bootSocial`, `bootSocialWith`, `freshDB`), `fakeprovider_test.go`, `unit_helpers_test.go` |
|
||||
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched repo |
|
||||
| **Full suite command** | `go vet ./... && go test ./...` in summercms.go, the user plugin checkout, sm-sociallogin-plugin, sm-demo-app, sm-bm-app (+ `npm --prefix frontend run test && npm --prefix frontend run typecheck`) and fonoteka.go |
|
||||
| **Estimated runtime** | ~30 seconds quick; ~5 minutes full (Docker) |
|
||||
| **Full suite command** | `scripts/check-phase19.5.sh --all` (summercms.go, sm-user-plugin through the fonoteka.go workspace, sm-sociallogin-plugin, sm-demo-app, sm-bm-app Go and frontend, fonoteka.go; the docker stage on a Docker host) |
|
||||
| **Estimated runtime** | ~30 seconds quick per repo; ~6 minutes for `--all` with Docker |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,35 +32,61 @@ created: "2026-10-19"
|
||||
|
||||
- **After every task commit:** Run the quick command in the touched repo
|
||||
- **After every plan wave:** Run the full command in every touched repo
|
||||
- **Before `/msd-verify-work`:** Full suite must be green in all six repos
|
||||
- **Before `/msd-verify-work`:** `scripts/check-phase19.5.sh --all` green (docker stage run, not NOT RUN)
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner and executor. Decision → test map from RESEARCH.md § Validation Architecture:
|
||||
One row per task of plans 01-04. Gate stages are modes of `scripts/check-phase19.5.sh`; repositories: F summercms.go, U sm-user-plugin, S sm-sociallogin-plugin, D sm-demo-app, B sm-bm-app, A fonoteka.go.
|
||||
|
||||
| Item | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|------|----------|-----------|-------------------|-------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; N concurrent pullers → one winner (memory, miniredis, Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ W0 | ⬜ pending |
|
||||
| D-13 | password user unlinks last identity → 204; password-less last → 409; two concurrent unlinks → one survives | integration | user plugin `go test ./... -run 'TestOAuthIdentitiesDestroy'` | ⚠ update existing | ⬜ pending |
|
||||
| D-15 | no sociallogin → `{"success":true,"providers":[]}`; enabled fakes → `[{name,label}]`; disabled excluded | unit | `-run 'TestOAuthProviders|TestProvidersList'` | ❌ | ⬜ pending |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete end to end | integration | sociallogin `-run TestFlow` | ❌ | ⬜ pending |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ | ⬜ pending |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes | unit | `-run TestStateCookie` | ❌ | ⬜ pending |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | unit | `-run TestNormalize` | ❌ | ⬜ pending |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets token cookie | integration | `-run TestOAuthComplete` | ❌ | ⬜ pending |
|
||||
| D-09 | register-complete 422 consent keeps code; 410; `email_exists_link_account`; concurrent submit → one 200 | integration | `-run TestRegisterComplete` | ❌ | ⬜ pending |
|
||||
| D-10 | matching branch table | integration | `-run TestMatching` | ❌ | ⬜ pending |
|
||||
| D-11 | email-verified per provider, nOAuth case | unit + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ | ⬜ pending |
|
||||
| D-12 | beta gate off/wrong/pending path | integration | `-run TestBetaGate` | ❌ | ⬜ pending |
|
||||
| D-14 | link via bearer/`token`/`auth_token`; no JWT; owned by another user | integration | `-run TestLink` | ❌ | ⬜ pending |
|
||||
| D-16 | settings override, secret never echoed, enabled iff id+secret, live change | integration | `-run TestSettings` | ❌ | ⬜ pending |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at` | integration | `-run TestIdentityTokens` | ❌ | ⬜ pending |
|
||||
| D-20 | route middleware throttles | unit | `-run TestRouteMiddleware` | ❌ | ⬜ pending |
|
||||
| PHP map | one Go test per PHP social-auth test case | integration | `-run TestPHP` | ❌ | ⬜ pending |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from SPA fallback; fonoteka green at new pin | app tests | per-app full commands | ⚠ update existing | ⬜ pending |
|
||||
| Task | Decision | Test names | Gate stage | Status |
|
||||
|------|----------|------------|------------|--------|
|
||||
| 19.5-01-01 | D-02, D-08 | `TestPullSmoke`, `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation` (F); `TestPullDocker` (F); decision note names goth v1.82.0 | `--go`, `--docker`, `--docs` | ✅ green |
|
||||
| 19.5-01-02 | D-15, D-22 | `TestOAuthProvidersSourceSmoke`, `TestOAuthProviders` (U); `TestSocialHooksSmoke`, `TestRegisterUserPasswordLess`, `TestFinishLogin`, `TestRegistrationExports` (U) | `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-01-03 | D-13, D-24 | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace`, `TestOAuthIdentitiesDestroyConcurrentSmoke`, `TestOAuthIdentitiesDestroyLastIdentityWithPassword`, `TestBackfillHandlesOpensFromConfigWhenUnpublished` (U); additive and migration check | `--docker`, `--userplugin` | ✅ green |
|
||||
| 19.5-01-04 | D-02 (package check) | human checkpoint, reply "approved" (19.5-01-SUMMARY); `go list -deps .` has no gorilla/sessions or gopkg.in/yaml.v3, goth only in `classes/providers_goth.go` (S) | `--sociallogin` | ✅ green |
|
||||
| 19.5-02-01 | D-03, D-07, D-09 | `TestTracerSmoke`, `TestRegistry`, `TestStateCookie`, `TestCodes`, `TestOAuthComplete` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-02 | D-10, D-11, D-12, D-19, D-23 | `TestFlowSmoke`, `TestMatching`, `TestNOAuth`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestRegisterComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| 19.5-02-03 | D-04, D-06, D-14, D-17 | `TestLinkSmoke`, `TestLink`, `TestLinkTicket`, `TestRoutes`, `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-04 | D-02, D-11, D-16, D-20 | `TestGothProvidersSmoke`, `TestGothProviders`, `TestEmailVerified`, `TestErrorMessages` (S); `TestSettingsOverrideSmoke`, `TestSettings`, `TestRouteMiddleware`, `TestProvidersList` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-02-05 | D-21 (go-ahead) | remote operation: human checkpoint, reply "release v0.3.1 (Recommended)" (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-02-06 | D-21 (release) | remote operation: `git ls-remote --exit-code --tags origin` for summercms v0.3.1, sm-user-plugin v0.1.4, sm-sociallogin-plugin v0.1.0 (19.5-02-SUMMARY) | (none: remote operation) | ✅ recorded |
|
||||
| 19.5-03-01 | D-21 (demo) | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D) | `--demo` | ✅ green |
|
||||
| 19.5-03-02 | D-04, D-21 (bm backend) | `TestSocialLoginMounted` (B); `TestStaticReservedPaths`, `TestEmbeddedPublicHasIndex` (B `plugins/jz/bm`) | `--bm` | ✅ green |
|
||||
| 19.5-03-03 | D-06, D-23 (bm frontend) | vitest `src/lib/oauth.test.ts` and the frontend suite, `npm run typecheck` (B) | `--bm` | ✅ green |
|
||||
| 19.5-03-04 | D-24 (fonoteka) | fonoteka.go `go vet ./...`, `go test -short ./...`; `summer.yaml` does not mount golem15.sociallogin (A) | `--fonoteka` | ✅ green |
|
||||
| 19.5-04-01 | gate (T-19.5-31) | `scripts/check-phase19.5.sh --self-test` (F) | `--self-test` | ✅ green |
|
||||
| 19.5-04-02 | D-08, D-13, D-15, D-22 | the cooler and sm-user-plugin unit tests of rows 19.5-01-01 to -03 (F, U) | `--go`, `--userplugin`, `--docker` | ✅ green |
|
||||
| 19.5-04-03 | D-03..D-20, D-23 | the 25 plugin unit tests of rows 19.5-02-01 to -04 (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-04 | PHP map | `TestPHPMethodMap` (S); `TestPHPSocialAuthGate`, `TestPHPOAuthLinkCookie`, `TestPHPPendingRegistrationTakeover`, `TestPHPPasswordBootstrap`, `TestPHPProfileOAuthLink` (S); `TestChangePassword/real_password_account_still_requires_current_password` (U) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| 19.5-04-05 | D-11 review, SC-4 | `19.5-SECURITY-REVIEW.md` (every T-19.5 threat to a test or an accepted reason); the whole gate | `--all` | ✅ green |
|
||||
| 19.5-04-06 | D-21 (post-release go-ahead) | remote operation: Task 7's acceptance criteria (no patch tag before the reply; the reply recorded verbatim) | (none: remote operation) | ⬜ awaiting the Task 6 reply |
|
||||
| 19.5-04-07 | D-21 (post-release release) | remote operation: Task 7's acceptance criteria (`merge-base --is-ancestor` per fix and pin, tags on the remotes, `origin/master..master` empty) and `--sociallogin`, `--demo`, `--bm`, `--fonoteka` at the new pins | `--sociallogin`, `--demo`, `--bm`, `--fonoteka` | ⬜ awaiting the Task 6 reply |
|
||||
|
||||
Decision coverage (from RESEARCH § Validation Architecture):
|
||||
|
||||
| Item | Behavior | Tests (repo) | Gate stage | Status |
|
||||
|------|----------|--------------|------------|--------|
|
||||
| D-08 | `Pull` returns and removes once; expired → not found; 64 concurrent pullers → one winner (memory, miniredis, Redis) | `TestPullConcurrent`, `TestPullExpired`, `TestPullPrefixIsolation`, `TestPullDocker` (F) | `--go`, `--docker` | ✅ green |
|
||||
| D-13 | self-set password unlinks the last identity → 204; password-less last → 409; two concurrent unlinks → one survives | `TestOAuthIdentitiesDestroyRule`, `TestOAuthIdentitiesDestroyRace` (U) | `--docker` | ✅ green |
|
||||
| D-15 | no source → `{"providers":[],"success":true}`; enabled providers in order; disabled excluded | `TestOAuthProviders` (U), `TestProvidersList` (S) | `--userplugin`, `--docker` | ✅ green |
|
||||
| D-03 | fake provider drives redirect → callback → code → complete | `TestTracerSmoke`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-04 | unknown/reserved name → 404; disabled → `?error=`; registry refuses reserved names | `TestRoutes`, `TestRegistry` (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-07 | missing/tampered/expired/mismatched state → `invalid_state`; cookie cleared; attributes; key separation | `TestStateCookie` (S) | `--docker` | ✅ green |
|
||||
| D-06/D-17 | callback and `return_to` normalisation tables | `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin` | ✅ green |
|
||||
| D-09 | complete 422/410/200/second-use 410; cookie mode sets the token cookie | `TestOAuthComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green |
|
||||
| D-09 | register-complete 422 consent keeps the code; 410; `email_exists_link_account`; 16 concurrent submits → one 200 | `TestRegisterComplete` (S) | `--docker` | ✅ green |
|
||||
| D-10 | matching branch table, soft-deleted, inactive and banned | `TestMatching`, `TestSoftDeleted`, `TestInactiveAndBanned` (S) | `--docker` | ✅ green |
|
||||
| D-11 | email-verified per provider, nOAuth case | `TestEmailVerified` (S, `--sociallogin`), `TestNOAuth` (S, `--docker`) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-12 | beta gate off/wrong/pending path | `TestBetaGate` (S) | `--docker` | ✅ green |
|
||||
| D-14 | link via bearer/`token`/`auth_token`/ticket; no JWT in URLs; owned by another user | `TestLink`, `TestLinkTicket` (S) | `--docker` | ✅ green |
|
||||
| D-16 | settings override, secret never echoed or logged, enabled iff id+secret, live change, legacy env | `TestSettings` (S) | `--docker` | ✅ green |
|
||||
| D-19 | provider tokens encrypted with `token_expires_at`; profile data only | `TestIdentityTokens` (S) | `--docker` | ✅ green |
|
||||
| D-20 | route middleware throttles | `TestRouteMiddleware` (S) | `--docker` | ✅ green |
|
||||
| PHP map | one Go test per PHP social-auth test case (38 methods) | `TestPHPMethodMap` and the five `TestPHP*` ports (S) | `--sociallogin`, `--docker` | ✅ green |
|
||||
| D-21 | demo and bm boot with sociallogin; bm `/oauth` reserved from the SPA fallback; fonoteka green at the new pin | `TestBootAllPlugins`, `TestSocialLoginRedirect`, `TestSocialLoginCoexistsWithOAuthServer` (D); `TestSocialLoginMounted`, `TestStaticReservedPaths` (B); fonoteka short suite (A) | `--demo`, `--bm`, `--fonoteka` | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -67,10 +94,10 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [ ] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [ ] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [ ] User plugin unlink race test with two explicit transactions
|
||||
- [x] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (Postgres harness booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [x] Fake `Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [x] `modules/cooler` Pull tests (memory, miniredis, docker Redis)
|
||||
- [x] User plugin unlink race test with two explicit transactions
|
||||
|
||||
---
|
||||
|
||||
@@ -84,11 +111,11 @@ Filled in by the planner and executor. Decision → test map from RESEARCH.md §
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 60s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** validated 2026-10-21 (plan 19.5-04); rows 19.5-04-06 and -07 are remote operations that follow the Task 6 reply.
|
||||
|
||||
Reference in New Issue
Block a user