docs(19.5): research and validation strategy
This commit is contained in:
@@ -0,0 +1,902 @@
|
||||
# Phase 19.5: Social login in sm-user-plugin - Research
|
||||
|
||||
**Researched:** 2026-10-19
|
||||
**Domain:** OAuth 2.0 client (social sign-in) as a shared SummerCMS plugin. goth without gothic, an encrypted state cookie, one-time codes on `cooler`, and account matching ported from PHP `SocialAuth`
|
||||
**Confidence:** HIGH for the code facts and the goth internals (read this session), MEDIUM for the provider behaviour that only live credentials can confirm
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Plugin and library
|
||||
- **D-01:** Social login is a **separate plugin**: repo `sm-sociallogin-plugin`, Go plugin `golem15.sociallogin`, and `Requires()` the user plugin. An app without social login gets no goth dependency, no `/oauth/*` routes, no provider config and no extra attack surface. Each new provider ships in this repo, so the shared core plugin changes less often. This follows the 19.4 pattern (`sm-oauthserver-plugin` requires `sm-user-plugin`). Module path = repo path, mounted in apps as a git submodule at `plugins/golem15/sociallogin` (`.planning/notes/core-plugins-own-repos.md`). — **Reversibility:** costly — apps' `summer.yaml`, submodules and config keys reference the plugin once it ships.
|
||||
- **D-02:** The OAuth client is **`github.com/markbates/goth` core plus its provider packages, without `gothic`**. `gothic` keeps sessions in a `gorilla/sessions` store, a second session mechanism next to bouncer, so it is not imported. The plugin keeps goth's marshalled session itself (D-07). Go links only imported packages, so `gorilla/sessions` may appear in `go.sum` but must not reach the binary. A decision note under `.planning/notes/` names goth and its pinned version before code lands (CLAUDE.md dependency rule). The researcher should report the binary-size delta (estimated 1–2 MB) and confirm which goth packages pull which transitive imports.
|
||||
- **D-03:** The plugin has its **own exported `Provider` interface and a registration function**. Built-in providers wrap goth's. An application or another plugin can register an extra provider in Go without forking. goth's types should not leak through the public API where a plugin-owned type will do (the researcher and planner decide how thin the wrapper is). Unit tests register **fake providers through the same registry**, so the redirect, callback, completion and pending branches run end to end with no network.
|
||||
- **D-04:** Providers: **google, facebook, github, microsoft**. The paths `/oauth/google` and `/oauth/facebook` (and `/github`) keep PHP's paths because desktop shells hard-code them. Microsoft is new: route `/oauth/microsoft`, goth's `azureadv2` provider (or whichever goth package fits Entra ID v2; research confirms). Its tenant is configurable (`common` | `organizations` | tenant ID), default `common`. The route accepts only registered, enabled provider names.
|
||||
|
||||
#### Mode, state and handoff
|
||||
- **D-05:** **SPA mode only, as in Horoskopia.** The PHP web mode (Winter session login, redirect to `/select`, `/parent/settings`, `/login`, `/register`) is QuestStream-era dead code: no Horoskopia or wavepath client uses it. When a request has no valid `frontend_callback`, the result goes to a configured default callback (for example `{frontend_url}/auth/oauth/callback`), so direct links and desktop shells still land on the SPA. `mode=spa` is accepted, and a missing or other `mode` is treated as SPA.
|
||||
- **D-06:** The query contract follows the Horoskopia frontend (`vue-horoskopia-app/app/stores/auth.ts:432-456`):
|
||||
- `/oauth/{p}` takes `mode`, `action=login|register|link`, `frontend_callback`, `return_to`, and on register `terms_accepted`, `privacy_accepted`, `marketing_consent`.
|
||||
- The callback redirects to `{frontend_callback}?code=…` on success.
|
||||
- Pending registration redirects to `?pending_registration=<code>[&reason=][&provider=][&email=]`.
|
||||
- Errors redirect to `?error=<urlencoded human message>`. This is a message, not a code, as in PHP.
|
||||
- `return_to` must start with `/` and not `//`, otherwise it becomes `/`.
|
||||
- **D-07:** **OAuth state and context live in one encrypted, short-lived cookie.** The cookie holds the state, `action`, `frontend_callback`, `return_to`, consent fields with their timestamp, and goth's marshalled session. It is encrypted with the app key, httpOnly, `SameSite=Lax` (the provider's return is a top-level GET), scoped to path `/oauth`, and lasts about 10 minutes. It is cleared on callback. The callback rejects a missing or mismatched state with PHP's `oauth.invalid_state` message path. This binds the flow to the browser, as PHP's session did (blocks login CSRF), and works on any instance without Redis.
|
||||
- **D-08:** **Add an atomic `Pull` (get-and-delete) to `cooler.Cache`** in `summercms.go`: Redis `GETDEL` and a locked delete in the memory driver. It is additive, ships in a framework minor release, and the plugin pins that release. Both one-time codes use it:
|
||||
- `oauth-complete:{code}`: 64 random chars, TTL 5 min, payload `{token, user, action, return_to}`.
|
||||
- `oauth-pending-registration:{code}`: TTL 10 min, with the PHP fields (`provider`, `provider_id`, `token`, `refresh_token`, `expires_in`, `name`, `email`, `avatar`, `return_to`, `email_verified`, `reason`).
|
||||
|
||||
The pending code is pulled atomically on a successful `oauth-register-complete`, which is stricter than PHP's get+forget. A validation 422 must not consume it, so the user can fix the terms checkboxes and retry. The planner decides how (for example, validate before pulling). — **Reversibility:** costly — a published `cooler` API method.
|
||||
- **D-09:** **Completion response.** By default, `GET /_user/api/v1/oauth-complete?code=` returns 200 `{token, user, action, return_to}` as in PHP, 422 on an empty code, and 410 on a missing or used code. An **opt-in config flag** sets bouncer's `token` cookie (19.3 `CookieConfig.TokenCookie`) and leaves `token` out of the body, so cookie-based apps like wavepath need no wrapper route. 19.3 D-01 holds, because the app opts in. `POST oauth-register-complete` keeps PHP's contract: `pending_code`, `terms_accepted`, `privacy_accepted`, `marketing_consent`; 422 `{error, errors:{terms_accepted,privacy_accepted}}`; 410 for a bad code; 422 `email_exists_link_account` for an unverified email match. The `/_user/api/v1/oauth-complete` and `oauth-register-complete` paths are mounted by sociallogin, because the user plugin has no route there today.
|
||||
|
||||
#### Matching, registration and identities
|
||||
- **D-10:** **Account matching is ported from PHP verbatim** (`SocialAuth::handleOAuthLogin`, `completePendingRegistration`):
|
||||
1. An existing `(provider, provider_id)` identity logs that user in.
|
||||
2. No email → pending `reason=fb_no_email`.
|
||||
3. An email that matches an existing user auto-links only if the provider asserts the email is verified, otherwise pending `reason=unverified_match`.
|
||||
4. No match → pending (SPA).
|
||||
5. Before login: the user is activated and not banned.
|
||||
|
||||
Registration checks `allow_registration`, the register throttle, and terms and privacy consent (and `parental_authority` if sent) at most **600 s** old. A new user gets `is_activated=true`, a random password, `has_self_set_password=false`, `password_bootstrap_source='oauth_registration'`, a username from the email local part plus a uniqueness counter, and the default group. Consent is recorded, plus the marketing consent audit if opted in. Events: register, then login, through the user plugin's existing `FireRegisterEvent`/`FireCreatedEvent` and login path. The avatar goes only into `profile_data.avatar`. — **Reversibility:** one-way — auto-linking by email cannot be undone for accounts it already merged.
|
||||
- **D-11:** **Which providers count as email-verified:**
|
||||
|
||||
| Provider | Verified? |
|
||||
|---|---|
|
||||
| Google | its `email_verified`/`verified_email` field (research confirms the exact field goth exposes in `RawData`) |
|
||||
| GitHub | true, as in PHP |
|
||||
| Facebook | false |
|
||||
| Microsoft | **always false** |
|
||||
|
||||
Microsoft's `email` claim can be set by a tenant admin (the "nOAuth" account-takeover class), so a Microsoft login never auto-links to an existing account by email. It always goes through `unverified_match`. Security-relevant: the phase gets a security review of the matching and state code.
|
||||
- **D-12:** **Optional beta gate ported**: `golem15.sociallogin.beta_key` (mapped from `BETATESTER_KEY`). It is off when empty and compared in constant time on registration. It is used on beta-stage projects (WavePath, QuestStream and others).
|
||||
- **D-13:** **Unlink aligned with PHP and made atomic** in sm-user-plugin. `OAuthIdentitiesDestroy` becomes one guarded DELETE that succeeds when `has_self_set_password` is true **or** the user has more than one identity (PHP `User.php:913`). Today's count-then-delete always blocks the last identity, even with a password. The existing test `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword` is updated to the PHP rule. This is a small additive behaviour relaxation; the status codes and body shape stay the same.
|
||||
- **D-14:** **Identity list/unlink stay host-mounted** as today: sm-user-plugin exports the handlers and apps choose the path (fonoteka `plugins/golem15/fonoteka/routes.go:240-246`, wavepath's `DELETE /_wave/api/v1/auth/profile/oauth/{provider}`). `action=link` is in sociallogin and needs an authenticated user. As in PHP `:779`, it is resolved from the bearer JWT or the `token`/`auth_token` cookie. Linking an identity already owned by another user is refused.
|
||||
- **D-15:** **`GET /_user/api/v1/oauth-providers` stays owned by sm-user-plugin.** It gets the provider list from a service that sociallogin publishes in the app's service registry (`app.Lookup`). With no sociallogin plugin it still returns `{"success":true,"providers":[]}`, so the path and payload don't change. Fonoteka's Nuxt store (`vue-fonoteka-app/app/stores/auth.ts:788`) and its parity corpus (`fonoteka.go/parity/check_corpus.go:37`) depend on it. With sociallogin, the response is `{success:true, providers:[{name,label}]}` for enabled providers only.
|
||||
|
||||
#### Configuration
|
||||
- **D-16:** **Credentials come from config and env, with an admin Settings override**, as in PHP (`Plugin.php:378`). The config keys are `golem15.sociallogin.providers.<p>.client_id|client_secret` (+ `tenant` for microsoft, + optional `redirect`), mapped from `GOOGLE_CLIENT_ID/SECRET`, `FACEBOOK_*`, `GITHUB_*` and `MICROSOFT_*`. sociallogin has a `pact.HasSettings` screen whose `<p>_client_id/secret` values override config. The secret is stored encrypted, is never echoed back in full to the admin form and is never logged. A provider is **enabled when its id and secret are both non-empty** after the override. Settings changes take effect without a restart.
|
||||
- **D-17:** **`frontend_callback` validation uses PHP's rules plus config extras.** The path must equal the configured callback path (default `/auth/oauth/callback`). A relative value is allowed. An absolute value must be http/https with an **exact** host match against: localhost, 127.0.0.1, the request host, the app URL host, the frontend URL host, and a configured extra allowed-hosts list (for split www/api domains). No wildcards.
|
||||
- **D-18:** The provider **redirect URI** defaults to the app's public URL + `/oauth/{p}/callback` (PHP: `CMS_URL`, then `APP_URL`). An optional per-provider `redirect` override mirrors PHP's `services.<p>.redirect`.
|
||||
- **D-19:** **Provider access and refresh tokens are stored on the identity row** with `token_expires_at`, as PHP does. The columns already exist as `lagoon.Encrypted`. This keeps parity and leaves room for later provider API use (for example Microsoft Graph in bm).
|
||||
|
||||
#### Routes and throttles
|
||||
- **D-20:** `/oauth/{provider}` and `/oauth/{provider}/callback` keep PHP's `throttle:20,1`. The `/_user/api/v1/oauth-*` routes sit under the user API's `throttle:user-api` group semantics. No JWT is required except for `action=link`.
|
||||
|
||||
#### Adoption
|
||||
- **D-21:** In this phase, **sm-demo-app** mounts sociallogin (every shared plugin goes into the demo instance), and **sm-bm-app** mounts it for Microsoft 365, with config and the frontend callback wiring wherever bm's frontend lives. fonoteka.go does not mount it, but must build and pass its tests at the bumped sm-user-plugin pin. Order of commits: framework release (cooler `Pull`) → sm-user-plugin → sm-sociallogin-plugin pushed → app pointer bumps as separate commits. Push, tag and repo creation on git.golem15.com need the user's go-ahead.
|
||||
|
||||
#### Folded Todos
|
||||
- **Let the sm-user-plugin console commands open their own database** (`.planning/todos/pending/user-plugin-commands-open-database.md`). `user:token:create|list|revoke` and `user:require-password-change` only look up a published `*gorm.DB`, so they fail in the real binary. The fix is the published-else-open-and-publish helper (pattern from fonoteka 14.2-01). The sm-bm-app checkout of sm-user-plugin has a commit `24b16b1 fix: open Postgres for one-shot console commands` that fonoteka's checkout lacks, so the planner first checks whether it already covers this and merges the two lines of sm-user-plugin history (fonoteka is at `c077029`, sm-bm-app at `24b16b1`, common base `7a6fa0e`). It fits here because 19.5 modifies sm-user-plugin and bumps its pin in both apps anyway.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact Go identifiers: the Provider interface shape, the registry function, the provider-list service type and the cookie name.
|
||||
- The cookie payload encoding (as long as it is encrypted with the app key and size-bounded).
|
||||
- How thin the goth wrapper is.
|
||||
- The settings screen layout (stock fields).
|
||||
- The config key spellings for the default callback URL, the callback path, extra hosts and the cookie-flag option.
|
||||
- Whether the Facebook Graph API version is pinned in config.
|
||||
- Username generation details beyond "local part + counter".
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Web-mode fallback (server-side cookie login without an SPA): no client uses it. Add it only if a non-SPA app appears.
|
||||
- Showing linked social identities on the admin Users screen: not discussed and not in scope.
|
||||
|
||||
#### Reviewed Todos (not folded)
|
||||
- **Purge the Nuxt site cache on menu and FAQ admin saves**: already folded into 19.3.
|
||||
- **Verified link between a frontend user and a backend user** (g15office `tqyvnzxx`): a prerequisite of 19.7, unrelated to social login.
|
||||
- **Plugins register their frontend permission codes in Go** (g15office `hmguritm`): a prerequisite of 19.7.
|
||||
- **Reload open apps on deploy** and **uploads keep client extension (stored XSS)**: unrelated, or already handled in 19.3.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to this phase (TBD). The CONTEXT decisions are the source items. Where this research supports each one:
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| D-01 | Separate plugin `sm-sociallogin-plugin` | §Q6 (19.4 analog layout), §Recommended Project Structure |
|
||||
| D-02 | goth without gothic, decision note | §Q1 (pin v1.82.0, import graph, 132 KiB measured), §Package Legitimacy Audit |
|
||||
| D-03 | Own Provider interface + registry, fake providers | §Pattern 1, Code Example 1 |
|
||||
| D-04 | google, facebook, github, microsoft (`azureadv2`) | §Q2, Pitfalls 3-6, Pitfall 9 (reserved route names) |
|
||||
| D-05/D-06 | SPA-only, query contract | §Q4 (PHP contract table), Code Example 4 |
|
||||
| D-07 | Encrypted state cookie | §Q3, Pattern 2, Code Example 2 |
|
||||
| D-08 | `cooler.Cache.Pull` | §Q5, Code Example 3, Pitfall 12 |
|
||||
| D-09 | oauth-complete / register-complete responses | §Q4 response table |
|
||||
| D-10 | Matching and registration ported | §Q4, Pitfalls 1, 2, 10, 11, Open Questions 1-3 |
|
||||
| D-11 | Verified-email rules | Pitfalls 3 and 4 (exact goth fields), Security Domain |
|
||||
| D-12 | Beta gate | Pitfall 14 (pending-path bypass in PHP) |
|
||||
| D-13 | Atomic unlink | Pitfall 1 (the PHP guarded DELETE races on Postgres; measured), Code Example 5 |
|
||||
| D-14 | Host-mounted identity routes, link resolution | §Q5 (`classes.RequestUser`), Open Question 4 (bearer-only SPAs) |
|
||||
| D-15 | `oauth-providers` via service lookup | Pattern 3, Pitfall 7 (typed Publish) |
|
||||
| D-16 | Config + settings override | §Q5 (pgstripe `cabana.SecretFallbacks` analog) |
|
||||
| D-17/D-18 | Callback validation, redirect URI | Code Example 4, Security Domain |
|
||||
| D-19 | Tokens on identity row | §Q5 (model already has `lagoon.Encrypted` columns) |
|
||||
| D-20 | Throttles | §Q5 (`surf` inline `throttle:20,1`, `user-api` bucket) |
|
||||
| D-21 | Adoption in demo and bm, fonoteka still green | §Q7, Pitfall 13 (diverged user-plugin history), Open Question 4 |
|
||||
| Todo | User-plugin commands open their own DB | §Q5 (24b16b1 covers it; one-hunk merge conflict in `plugin.go`) |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions. These carry the weight of locked decisions:
|
||||
|
||||
- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (`.planning/research/STACK.md` holds the decided set). goth needs `.planning/notes/<date>-sociallogin-goth.md` **before** it lands in any `go.mod` (D-02).
|
||||
- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. goth's go.mod lists `gopkg.in/yaml.v3` as an indirect requirement. That puts it in the module graph but not in the import graph; the plugin must not import it.
|
||||
- **Framework code never names a consuming application.** The `cooler` README and docs change for `Pull` use neutral examples. The sociallogin plugin README also says "the application" (core-plugin note).
|
||||
- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages **in the same commit**: `modules/cooler/README.md`, `docs/services/caching.md`, the sm-user-plugin README (new exports), and the sociallogin README. The docs checker (`summer docs:build --check`) verifies identifiers.
|
||||
- Module/plugin README structure: H1 name, one-sentence summary, import line, `**Name:**` mnemonic, then Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing.
|
||||
- `go vet ./...` and `go test ./...` must stay green at every commit. Docker tests skip under `-short`, and a `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`.
|
||||
- **Lean planning:** few large plans. Present the plan count with a one-line scope each and wait for confirmation before writing PLAN.md files.
|
||||
- **Unit tests are the last plan of every phase.** Earlier plans may carry smoke tests.
|
||||
- Update `STATE.md` through `msd_run query state.*` handlers. Mirror the phase in g15office.
|
||||
- **No co-author tags** in commits. One logical change per commit, with planning docs and code in separate commits. Stage files by explicit path, never `git add -A`. Other sessions edit concurrently.
|
||||
- Shared core plugins: commit and push the plugin first, then the app pointer bump as a separate commit. Never stage files inside a submodule from the app repo. Core plugin contracts (routes, payloads, tables, migration IDs, config keys) stay non-breaking.
|
||||
- Don't push, tag, create repos or deploy without the user's go-ahead.
|
||||
- User memory: **for protocol and security code, recommend the maintained library plus a decision note** over hand-rolled code.
|
||||
|
||||
## Summary
|
||||
|
||||
The work splits cleanly along D-01. The framework gets one additive method, `cooler.Cache.Pull`: `GETDEL` on Redis and otter `Compute` with `InvalidateOp` in memory. No implementation of `cooler.Cache` exists outside `modules/cooler` anywhere in the workspace, so adding the method breaks nobody. sm-user-plugin gets small exported hooks:
|
||||
|
||||
- a provider-list interface that `OAuthProviders` looks up at request time;
|
||||
- a row-locked guarded unlink;
|
||||
- exported registration, throttle and refresh-TTL helpers;
|
||||
- a `RegisterOptions` switch for the OAuth password bootstrap;
|
||||
- the merge of its two diverged history lines.
|
||||
|
||||
Everything else lives in the new `sm-sociallogin-plugin`. The closest analog is `sm-oauthserver-plugin` (19.4): it is a sibling directory of `summercms.go`, its `go.work` replaces the framework with `../summercms.go` and the user plugin with `../fonoteka.go/plugins/golem15/user`, and its tests use a testcontainers Postgres `freshDB` harness.
|
||||
|
||||
goth v1.82.0 (2025-08-18, the newest tag on the module proxy) fits D-02. Its core package `goth` imports only the standard library and `golang.org/x/oauth2`. Only `gothic` imports `gorilla/sessions`. The four needed provider packages pull `golang.org/x/oauth2` and, for Google only, `golang.org/x/oauth2/google` plus `cloud.google.com/go/compute/metadata`. Linked into a copy of the real sm-demo-app build, the four providers plus goth core added **132 KiB** to a stripped binary (47,427,847 → 47,563,015 bytes), far below the 1–2 MB estimate. `go version -m` lists no `gorilla/*` session module in the binary. The app's module graph gains 19 entries (gorilla/sessions, lestrrat-go/jwx and others), none of them linked, and no existing module version changes. `azureadv2` is the right Microsoft package: `ProviderOptions.Tenant` takes `common`, `organizations`, `consumers` or a tenant ID, while `microsoftonline` hard-codes `common`.
|
||||
|
||||
The research found five things the CONTEXT did not anticipate:
|
||||
|
||||
1. **PHP's "atomic" guarded DELETE is not atomic on Postgres.** Two concurrent unlinks under READ COMMITTED both succeeded and left 0 identities (measured below). The Go port must lock the `users` row first (D-13).
|
||||
2. **The Go user model lacks several columns D-10 names:** `username`, `password_bootstrap_source`, the terms/privacy consent columns, a consent audit table and a default-group setting. There is also no login event. These need a user decision before the plan locks.
|
||||
3. **goth's GitHub and Google defaults differ from Socialite.** goth trusts GitHub's public profile email without checking it is verified, requests no `user:email` scope by default, and fails the whole fetch when no verified primary email exists. Google's v2 userinfo exposes `verified_email`, not `email_verified`, and the default scope is only `email`.
|
||||
4. **sm-user-plugin history has diverged.** Seven commits (including 19.4's `RequestUser`, which sociallogin needs for `action=link`) exist only in fonoteka's local checkout, while `origin/master` has `24b16b1`. Merging them conflicts in one hunk of `plugin.go`.
|
||||
5. **bm stores its JWT in localStorage**, so a top-level navigation to `/oauth/microsoft?action=link` cannot carry it. Microsoft never auto-links (D-11), so existing bm accounts could not attach Microsoft at all without an extra mechanism.
|
||||
|
||||
**Primary recommendation:** Build sociallogin on a per-request provider factory registry that wraps goth (no gothic) and returns a plugin-owned `Profile` type. Seal the state cookie with stdlib AES-GCM under an HKDF-derived key from `lagoon.LoadAppKey`. Use `cooler.Pull` for both codes. Make the user-plugin unlink take `SELECT … FOR UPDATE` on the user row before the guarded DELETE. Resolve the five gaps above (Open Questions 1–4) before planning locks.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Redirect to provider, state cookie | API / Backend (sociallogin `/oauth/{p}`) | Browser (holds cookie) | State must be bound to the browser but unforgeable, so the server issues it as an AEAD-sealed httpOnly cookie |
|
||||
| Provider code exchange, profile fetch | API / Backend (goth wrappers) | — | Needs the client secret; never in the browser |
|
||||
| Account matching, registration, linking | API / Backend (sociallogin + user plugin helpers) | Database (unique indexes as last guard) | Security decisions (D-10/D-11) must be server-side |
|
||||
| One-time codes (complete, pending) | Database / Storage (`cooler` Redis or memory) | API | Codes must survive the redirect hop. Memory works only on one instance |
|
||||
| Code exchange → JWT | API (`/_user/api/v1/oauth-complete`) | Browser (SPA stores the token, or gets a cookie under D-09) | Keeps the JWT out of URLs |
|
||||
| Pending registration consent UI | Browser / Client (SPA page) | API (`oauth-register-complete`) | Consent is a user action. The server validates it |
|
||||
| Provider list for login buttons | API (user plugin `oauth-providers`) | sociallogin (published service) | User plugin owns the path (D-15) |
|
||||
| Identity list/unlink | API (user plugin handlers, host-mounted) | Database (row lock + guarded DELETE) | Race safety lives in the DB transaction |
|
||||
| Credentials and settings | Database (encrypted settings row) | Config/env fallback | Change without restart (D-16) |
|
||||
|
||||
## Answers to the CONTEXT research questions
|
||||
|
||||
### Q1. goth: version, import graph, gorilla/sessions, binary size
|
||||
|
||||
- **Pin:** `github.com/markbates/goth v1.82.0`, published 2025-08-18T03:17:23Z, origin `https://github.com/markbates/goth` at tag `v1.82.0` (hash `6c7fc319f1f5…`), `go 1.23.0` in its go.mod. `go list -m -versions` shows v1.82.0 as the newest tag. [VERIFIED: `go list -m -json github.com/markbates/goth@v1.82.0` against proxy.golang.org]
|
||||
- **Import graph** (from `go list -deps` on a scratch module that imports `goth` + `providers/{google,facebook,github,azureadv2}`). These are the only non-stdlib packages:
|
||||
```
|
||||
cloud.google.com/go/compute/metadata
|
||||
github.com/markbates/goth
|
||||
github.com/markbates/goth/providers/azureadv2
|
||||
github.com/markbates/goth/providers/facebook
|
||||
github.com/markbates/goth/providers/github
|
||||
github.com/markbates/goth/providers/google
|
||||
golang.org/x/oauth2
|
||||
golang.org/x/oauth2/authhandler
|
||||
golang.org/x/oauth2/google
|
||||
golang.org/x/oauth2/google/externalaccount
|
||||
golang.org/x/oauth2/google/internal/externalaccountauthorizeduser
|
||||
golang.org/x/oauth2/google/internal/impersonate
|
||||
golang.org/x/oauth2/google/internal/stsexchange
|
||||
golang.org/x/oauth2/internal
|
||||
golang.org/x/oauth2/jws
|
||||
golang.org/x/oauth2/jwt
|
||||
```
|
||||
[VERIFIED: go list -deps, this session]
|
||||
- `goth` (core) imports `context`, `fmt`, `net/http`, `sync`, `encoding/gob`, `time`, `golang.org/x/oauth2`. [VERIFIED: goth@v1.82.0/provider.go:3-10, user.go:3-6]
|
||||
- `providers/google` adds `golang.org/x/oauth2/google` via `endpoint.go` (`goog.Endpoint`), which brings `cloud.google.com/go/compute/metadata` and the `oauth2/google/*` subpackages. [VERIFIED: providers/google/endpoint.go]
|
||||
- `providers/facebook`, `github` and `azureadv2` import only `goth`, `golang.org/x/oauth2` and stdlib. [VERIFIED: source read]
|
||||
- `providers/microsoftonline` (not recommended) adds `github.com/markbates/going/defaults`.
|
||||
- `gothic/gothic.go:24` imports `"github.com/gorilla/sessions"`. It is the **only** goth package that does. [VERIFIED: gothic/gothic.go:11-25]
|
||||
- **gorilla/sessions does not reach the binary.** A real sm-demo-app build with the four providers driven through BeginAuth/Unmarshal/Authorize/FetchUser lists `github.com/markbates/goth v1.82.0`, `golang.org/x/oauth2 v0.36.0` and `cloud.google.com/go/compute/metadata v0.9.0` in `go version -m`. Its only gorilla module is the pre-existing `github.com/gorilla/css`; there is no `gorilla/sessions`, `securecookie`, `mux`, `pat` or `lestrrat-go/*`. [VERIFIED: measured, scratchpad build]
|
||||
- **Module graph** (`go list -m all`, demo workspace): it gains 19 modules: `github.com/decred/dcrd/dcrec/secp256k1/v4`, `go-chi/chi/v5`, `goccy/go-json`, `gorilla/{context,mux,pat,securecookie,sessions}`, `jarcoal/httpmock`, `lestrrat-go/{backoff/v2,blackmagic,httpcc,iter,jwx,option}`, `markbates/{going,goth}`, `mrjones/oauth`, `pkg/errors`. **No existing module version changed** (diff had only additions). They appear in `go.sum`/`go.work.sum` only. [VERIFIED: diff of `go list -m all`, this session]
|
||||
- **Binary-size delta: +132 KiB stripped** (`-trimpath -ldflags="-s -w"`): 47,427,847 → 47,563,015 bytes for sm-demo-app. The demo already links `google.golang.org/api` (through gocloud), which is why the delta is small. With blank imports only, the delta is 4 KiB, because the linker drops unused code. [VERIFIED: measured with a `go build -overlay` probe in a scratch go.work; no repo file touched]
|
||||
- **Vulnerabilities:** `govulncheck@v1.9.0` over the scratch module (goth v1.82.0, oauth2 v0.27.0, metadata v0.3.0) reported "No vulnerabilities found." [VERIFIED: govulncheck run this session]
|
||||
- **Maintenance:** 6.6k stars, MIT, 825 commits, 102 open issues, 43 open PRs. The repo's About link points at a post slugged "goth-needs-a-new-maintainer", and the last tag is 14 months old. [CITED: github.com/markbates/goth] That risk is contained by D-03: the wrapper keeps goth out of the public API, and each provider package is under 300 lines.
|
||||
|
||||
### Q2. Microsoft Entra ID v2: `azureadv2` vs `microsoftonline`
|
||||
|
||||
Use **`providers/azureadv2`**. [VERIFIED: source read]
|
||||
|
||||
| | `azureadv2` | `microsoftonline` |
|
||||
|---|---|---|
|
||||
| Endpoints | `https://login.microsoftonline.com/%s/oauth2/v2.0/authorize` and `/token` with the tenant substituted (azureadv2.go:15-16) | hard-coded `…/common/oauth2/v2.0/…` (microsoftonline.go:19-20) |
|
||||
| Tenant | `azureadv2.New(id, secret, cb, azureadv2.ProviderOptions{Tenant: azureadv2.TenantType("<common|organizations|consumers|tenant-guid-or-domain>"), Scopes: …})`. An empty tenant becomes `CommonTenant` (azureadv2.go:76-79) | has an unexported, unused `tenant` field and no setter |
|
||||
| Default scopes | `openid profile email User.Read` (azureadv2.go:95) | `openid offline_access user.read` |
|
||||
| Extra deps | none | `github.com/markbates/going/defaults` |
|
||||
| Profile | Graph `GET https://graph.microsoft.com/v1.0/me`: `UserID = id`, `Email = mail`, `Name = displayName`; RawData holds the whole `/me` JSON including `userPrincipalName` (azureadv2.go:186-223) | Graph `/me` too |
|
||||
|
||||
Constants: `CommonTenant TenantType = "common"`, `OrganizationsTenant TenantType = "organizations"`, `ConsumersTenant TenantType = "consumers"` (azureadv2.go:51-57). The default provider name is `"azureadv2"` (azureadv2.go:68), so the wrapper must call `SetName("microsoft")` or keep its own name.
|
||||
|
||||
What the wrapper must add for D-04/D-19:
|
||||
- Pass scopes explicitly, including `azureadv2.OfflineAccessScope` (`"offline_access"`, scopes.go:57). The defaults omit it, so no refresh token would arrive.
|
||||
- Treat `AvatarURL` as unusable as a public avatar. goth sets it to `graphAPIResource + "users/{id}/photo/$value"` (azureadv2.go:217), a URL that needs the bearer token.
|
||||
- `Email` comes from Graph `mail`, which is empty for many personal accounts and for users without a mailbox. See Open Question 5.
|
||||
|
||||
### Q3. Driving goth without gothic (D-07)
|
||||
|
||||
What gothic does, from `gothic/gothic.go` `BeginAuthHandler`/`GetAuthURL` (:62-131) and `CompleteUserAuth`/`validateState` (:184, :219-237), reimplemented with the plugin's own cookie:
|
||||
|
||||
1. **Begin** (`GET /oauth/{p}`): `state := random(32 bytes) → base64url`. `sess, err := gp.BeginAuth(state)`, then `authURL, err := sess.GetAuthURL()` and `blob := sess.Marshal()`. Seal `{v, state, provider, action, frontend_callback, return_to, consent{terms,privacy,marketing,parental?,beta_key?,ts}, link_uid?, locale, goth_session: blob, iat}` into the cookie. Respond 302 to `authURL` with `Cache-Control: no-store`.
|
||||
2. **Callback** (`GET /oauth/{p}/callback`): open the cookie, then clear it immediately (MaxAge -1, same Path). Check `cookie.provider == {p}` and do a constant-time compare of `cookie.state` against `r.URL.Query().Get("state")`. **Reject an empty state.** gothic skips the check when the original state is empty (`if originalState != "" && …`, gothic.go:233); do not copy that. A missing or mismatched state goes to `invalid_state`. If the query has `error=` (user cancelled), redirect with `provider_error`.
|
||||
3. `sess, err := gp.UnmarshalSession(cookie.goth_session)`, then `_, err = sess.Authorize(gp, r.URL.Query())`. This is the code exchange: `p.config.Exchange(ctx, params.Get("code"))` in every provider's session.go. Then `u, err := gp.FetchUser(sess)`.
|
||||
4. Map errors: `errors.As(err, &*oauth2.RetrieveError)` with `ErrorCode` of `invalid_grant` (Google, Microsoft, Facebook) or `bad_verification_code` (GitHub) gives `code_expired`. Any other error gives `provider_error` with `Authentication failed`/`Unexpected error`. `RetrieveError.ErrorCode` exists in oauth2 (token.go:187-198). [VERIFIED: golang.org/x/oauth2@v0.27.0/token.go, internal/token.go:285-326]
|
||||
|
||||
Session payloads (all JSON via `Marshal()`): google `{AuthURL, AccessToken, RefreshToken, ExpiresAt, IDToken}`; azureadv2 `{"au","at","rt","exp"}`. At Begin time only the AuthURL is filled, roughly 300–700 bytes. [VERIFIED: providers/google/session.go:13-19, azureadv2/session.go:13-18]
|
||||
|
||||
goth calls take **no context**. HTTP goes through `p.HTTPClient`, or `http.DefaultClient` when it is nil (goth `HTTPClientWithFallBack`, provider.go:81-87), so there is **no timeout** by default. Set each provider's exported `HTTPClient` field to a client with a timeout (for example 10 s). Token exchange uses the same client through `goth.ContextForClient`.
|
||||
|
||||
### Q4. The PHP contract to reproduce
|
||||
|
||||
All values below come from files read this session: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/{components/SocialAuth.php, controllers/ApiController.php, routes.php, models/User.php, lang/*/lang.php}`.
|
||||
|
||||
**Routes** [VERIFIED: routes.php:238-252, :165-183, :20-21, :8-10]
|
||||
- `Route::group(['prefix' => 'oauth', 'middleware' => ['web', 'throttle:20,1']], …)` with `Route::get('/{provider}', …)->where('provider', 'google|facebook|github')` and `Route::get('/{provider}/callback', …)`.
|
||||
- In the `'/_user/api/v1'` group (`'throttle:user-api'`): `Route::get('oauth-providers', …)`, `Route::get('oauth-complete', …)`, `Route::post('oauth-register-complete', …)`.
|
||||
- `RateLimiter::for('user-api', … Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()))`.
|
||||
|
||||
**Redirect** (`onRedirectToProvider`, SocialAuth.php:111-191)
|
||||
- `action` defaults to `'login'`. Only for `action === 'register'` is consent stored: `terms_accepted`, `privacy_accepted`, `marketing_consent` as `(bool)` casts, `'timestamp' => now()->timestamp`, `parental_authority` only if sent, `beta_key` only if non-empty.
|
||||
- For `$action === 'link'`, the user must resolve (`resolveAuthenticatedUser`, :779-794: JWT `sub` first, then the Winter session), else `must_be_logged_in`.
|
||||
- Google gets `prompt=select_account` (`PROMPT_SELECT_ACCOUNT_PROVIDERS = ['google']`, :36). goth: `google.Provider.SetPrompt("select_account")` (google.go:176-181).
|
||||
- The Horoskopia client sends `terms_accepted`/`privacy_accepted` as `'1'`/`'0'` strings and `marketing_consent=1` only when true [VERIFIED: vue-horoskopia-app/app/stores/auth.ts:447-452]. Go must parse `"1"`/`"true"`/`"on"`/`"yes"` as true and `"0"`/empty as false (sm-user-plugin has an unexported `laravelBool`, api_controller.go:1252).
|
||||
|
||||
**Callback** (`onOAuthCallback`, :197-279) and **messages** (keys from `lang/en/lang.php:362-398`, `:217-273`):
|
||||
|
||||
| Situation | PHP outcome | en message | pl message |
|
||||
|---|---|---|---|
|
||||
| State mismatch | `?error=` invalid_state | `Your sign-in session has expired or could not be verified. Please try signing in again.` | (en fallback) |
|
||||
| `invalid_grant` | `?error=` code_expired | `Your login session has expired. Please try signing in again.` | (en) |
|
||||
| Other provider error | `?error=` provider_error with `'error' => 'Authentication failed'` or `'Unexpected error'` | `OAuth authentication failed: :error` | (en) |
|
||||
| No provider user id | `?error=` literal | `Nie udało się odczytać identyfikatora konta z dostawcy logowania.` (hard-coded Polish in PHP) | same |
|
||||
| Inactive / banned | `?error=` key `golem15.user::lang.account.account_not_activated` / `account_banned` | **these keys do not exist in en or pl**, so PHP shows the raw key string | — |
|
||||
| Registration disabled | `account.registration_disabled` | `Registrations are currently disabled.` | `Rejestracja jest obecnie wyłączona.` |
|
||||
| Register throttled | `account.registration_throttled` | `Registration is throttled. Please try again later.` | `Limit rejestracji` |
|
||||
| No/incomplete consent | `gdpr.consent_required` | `You must accept the required terms and policies before continuing with registration.` | (en) |
|
||||
| Consent > 600 s | `gdpr.consent_expired` | `Your consent session has expired. Please review and accept the terms again.` | (en) |
|
||||
| Beta key | `account.beta_key_invalid` | `The beta tester key you entered is invalid.` | (en) |
|
||||
| Link: owned by other user | `oauth.account_already_linked` (PHP passes no `:provider` param, so the placeholder shows literally) | `This :provider account is already linked to another user.` | (en) |
|
||||
| Unverified match at completion | 422 `email_exists_link_account` with `['provider' => ucfirst($provider)]` | `An account with this email already exists. Please sign in and link your :provider account from settings.` | `Konto z tym adresem e-mail już istnieje. Ze względów bezpieczeństwa nie łączymy go automatycznie z kontem :provider. Zaloguj się hasłem — w razie potrzeby skorzystaj z opcji przypomnienia hasła — a następnie połącz konto :provider w ustawieniach.` |
|
||||
| Invalid provider / not configured / invalid action / must log in | ApplicationException, raised before any redirect context exists for redirect-time errors | `Invalid OAuth provider specified.` / `:provider login is not configured. Please contact the administrator.` / `Invalid OAuth action specified.` / `You must be logged in to link an OAuth account.` | (en) |
|
||||
|
||||
**Branches** (:284-396 login, :401-633 register, :638-706 link):
|
||||
- **Login.** Identity by `(provider, provider_id)` → login. Else: empty email → pending `fb_no_email`; email matches a user → if `!providerEmailVerified` pending `unverified_match`, else auto-link + login; no match → (SPA) pending with no reason. Then `is_activated`, `isBanned()`, `linkOAuthProvider(…)` (updates tokens), `touchIpAddress`, `Event::fire('golem15.user.login')`, `completeSuccessfulAuth($user, 'login', …)`.
|
||||
- **Register (`action=register`).** Existing identity → login. Empty email → pending `fb_no_email`. Email match → same verified gate (unverified → pending, verified → login). Otherwise **the user is created directly at the callback** (no pending screen) after `allow_registration`, throttle, consent from the session (≤600 s, terms+privacy, parental if present) and the beta key when `require_beta_key`. **The planner must keep this second creation path.** D-10's "No match → pending" applies to `action=login` only.
|
||||
- **Link.** Resolve the authenticated user again at the callback. The identity belongs to another user → `account_already_linked`. Otherwise upsert, then a code with `action => 'link'` and a fresh JWT.
|
||||
- **Pending redirect** (:919-987): `?pending_registration=<urlenc>`, then `&reason=` only when non-null, then **always** `&provider=`, then `&email=` only when non-empty.
|
||||
- **`completePendingRegistration`** (:995-1108): existing identity → login. Email match → if `empty($payload['email_verified'])` throw `email_exists_link_account` (fail closed for old payloads), else login. Otherwise create: allow + throttle (consent comes from the request, so there is no timestamp check) and no beta check (see Pitfall 14). Then `recordConsent`, marketing audit, events `register` then `login`, and return action `'register'`.
|
||||
- **Normalizers** (:1185-1251): `normalizeReturnTo` ('' or not `/`-prefixed or `^//` → `/`). `normalizeFrontendCallback`: path must be exactly `/auth/oauth/callback`; no scheme or host → returns the relative `'/auth/oauth/callback'`; scheme not http/https → null; host in `['localhost','127.0.0.1', request host, APP_URL host, FRONTEND_URL host]`; returns `scheme://host[:port]/auth/oauth/callback`, which drops userinfo, query and fragment.
|
||||
- **Codes:** `Str::random(64)`, `OAUTH_COMPLETE_TTL_MINUTES = 5`, `OAUTH_PENDING_REGISTRATION_TTL_MINUTES = 10` (:32-33).
|
||||
|
||||
**API endpoints** [VERIFIED: ApiController.php:1066-1170]
|
||||
|
||||
| Endpoint | Success | Errors |
|
||||
|---|---|---|
|
||||
| `GET oauth-providers` | `{"success": true, "providers": [{"name","label"}]}`, labels `Google`, `Facebook`, `GitHub` (+ `Microsoft` in Go) | — |
|
||||
| `GET oauth-complete?code=` | 200 `{token, user, action ('login' default), return_to ('/' default)}` | 422 `{"error":"Missing OAuth completion code"}`; 410 `{"error":"OAuth completion code is invalid or expired"}` (`Cache::pull`) |
|
||||
| `POST oauth-register-complete` | 200 `{token, user, action ('register' default), return_to}` | 422 `{"error":"Missing pending registration code"}`; 410 `{"error":"OAuth registration session is invalid or expired"}`; 422 `{"error":"Musisz zaakceptować regulamin i politykę prywatności, aby dokończyć rejestrację.","errors":{"terms_accepted":["Musisz zaakceptować regulamin."] or [],"privacy_accepted":["Musisz zaakceptować politykę prywatności."] or []}}`; 422 `{"error": <ApplicationException message>}` |
|
||||
|
||||
The 422 consent message is hard-coded Polish in PHP. Reproduce it byte for byte, or move it to lang keys whose `pl` value is that text (the `en` text would be new; see Assumption A6).
|
||||
|
||||
**Unlink rule** [VERIFIED: User.php:913-932]: the DELETE's WHERE is `user_id = ? AND provider = ? AND (EXISTS (SELECT 1 FROM users WHERE users.id = … AND has_self_set_password = true) OR (SELECT COUNT(*) FROM golem15_user_oauth_identities AS oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`. See Pitfall 1 for why it is not race-safe on Postgres.
|
||||
|
||||
### Q5. Existing Go code to extend
|
||||
|
||||
**Where sm-user-plugin lives.** It is not a sibling directory. Each app has a submodule checkout: `fonoteka.go/plugins/golem15/user` (local `master` at `9335e80`, **7 commits ahead of `origin/master`, unpushed**: `569768e … c077029 b82d85f ee47440 80b436d 9335e80`), and `sm-bm-app`, `sm-demo-app`, `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app` at `24b16b1` (tag `v0.1.1` = `origin/master`). fonoteka.go *records* `cf5c6b9` but has `9335e80` checked out (`git status`: ` M plugins/golem15/user`). `sm-oauthserver-plugin/go.work` replaces the user plugin with `../fonoteka.go/plugins/golem15/user`. [VERIFIED: git log/branch/status this session] The CONTEXT says fonoteka is "at `c077029`"; that is now four commits stale. `git merge-tree master 24b16b1` reports one conflict, in `plugin.go`. `Commands()` must combine `console.BackfillHandles(p.app)` from the fonoteka line with `withDB` from `24b16b1`. `24b16b1` itself is exactly the folded todo's fix (`withDB` = published `*gorm.DB`, else `lagoon.OpenFromApp`), so the todo is covered by merging plus giving `BackfillHandles` the same `withDB`.
|
||||
|
||||
**Identity model and migration** [VERIFIED: models/oauth_identity.go:10-30, updates/202610050001_create_oauth_identities.go:13-27]
|
||||
```
|
||||
ID uint `gorm:"column:id;primaryKey"`; UserID uint `gorm:"column:user_id"`; Provider string `gorm:"column:provider"`;
|
||||
ProviderID string `gorm:"column:provider_id"`; AccessToken lagoon.Encrypted `gorm:"column:access_token" json:"-"`;
|
||||
RefreshToken lagoon.Encrypted `gorm:"column:refresh_token" json:"-"`; TokenExpiresAt *time.Time `gorm:"column:token_expires_at"`;
|
||||
ProfileData lagoon.Jsonable[map[string]any] `gorm:"column:profile_data" json:"-"`; LinkedAt *time.Time `gorm:"column:linked_at"`
|
||||
func (OAuthIdentity) TableName() string { return "golem15_user_oauth_identities" }
|
||||
`provider VARCHAR(50) NOT NULL`, `provider_id VARCHAR(255) NOT NULL`, `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
|
||||
`CREATE UNIQUE INDEX oauth_identities_user_provider_unique ON golem15_user_oauth_identities (user_id, provider)`
|
||||
`CREATE UNIQUE INDEX oauth_identities_provider_identity_unique ON golem15_user_oauth_identities (provider, provider_id)`
|
||||
```
|
||||
|
||||
**Handlers** [VERIFIED: controllers/oauth_identities.go:15-21, 53-101; api_controller.go:216-223; routes.go:9-31]
|
||||
- `type OAuthIdentitiesOptions struct { Providers []string; WriteNotFound func(http.ResponseWriter, *http.Request) }`, with the default `providers = []string{"google", "facebook", "github"}` (:57). **Microsoft must be added to the default**, or the list must come from the provider service. Otherwise `DELETE …/microsoft` returns 404.
|
||||
- Destroy today: `First` row → `Count` → `if n == 1 { 409 {"error": oauthLastMethodBlocked} }` → `Delete` → 204.
|
||||
- `OAuthProviders` stub: `writeJSON(w, http.StatusOK, map[string]any{"success": true, "providers": wire.Slice([]any{})})`.
|
||||
- Routes: `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { … g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) … })`.
|
||||
|
||||
**Registration and token helpers (already exported)** [VERIFIED: controllers/registration.go:26-29, 38-84, 90-111, 136-154]
|
||||
- `type RegisterOptions struct { Activate bool; IP string }`, `func RegisterUser(ctx, app, db, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error)`. It validates `(models.User{}).Rules()`, which are `"email": "required|between:6,255|email|unique:users"`, `"password": "required|between:8,255|confirmed"` (models/user.go:93-99). It **always sets `user.HasSelfSetPassword = true`** (:71) and calls `classes.AssignHandle` (:80).
|
||||
- `FireRegisterEvent(ctx, app, user, fields)`, `FireCreatedEvent(ctx, app, user)`, `IssueToken(app, user, r)`, `APIArray(ctx, app, user)`.
|
||||
- **Not exported** (sociallogin needs additive exports): `registrationSettings` (:685, config keys `golem15.user.registration.allow_registration`, `golem15.user.registration.use_register_throttle`, defaults `true`/`true`), `registrationsFromIP` (:708, `created_ip_address = ? AND created_at > now-60min`, blocked at `>= 3`), `refreshTTL` (:834, `golem15.user.jwt.refresh_ttl` minutes, default `20160`), `laravelBool` (:1252).
|
||||
- Login gating: `models.Throttle` has `IsBanned bool gorm:"column:is_banned"` on table `user_throttle` (models/throttle.go:13-18). `classes.RejectIfThrottled` also enforces login suspension, which is stricter than PHP's `isBanned()`.
|
||||
- **There is no login event** in Go (`classes/events.go` declares `GetApiArrayEvent`, `RegisterEvent`, `CreatedEvent`, `DeletingEvent`, `DeactivatingEvent`). The password login path does `CheckAndRecordLogin` and `classes.TouchLastSeen`.
|
||||
- `LookupByEmail` is `Unscoped().Where("email = ?", email)`: exact match, includes soft-deleted users (classes/user_lookup.go:116-129).
|
||||
|
||||
**User columns** [VERIFIED: updates/00_base.go, updates/202609220005_extend_users.go:13-30, models/user.go:14-59]: `email TEXT NOT NULL UNIQUE`, `has_self_set_password BOOLEAN NOT NULL DEFAULT TRUE`, `marketing_consent BOOLEAN NOT NULL DEFAULT FALSE`, `is_activated`, `created_ip_address`, `last_ip_address`, `handle`, `deleted_at`. **Absent:** `username`, `password_bootstrap_source`, `terms_accepted*`, `privacy_accepted*`, `*_version`, `consent_ip_address`, `marketing_consent_at`, any consent-audit table, any `default_group` setting. See Open Question 1.
|
||||
|
||||
**Link-user resolution already exists** (fonoteka line only, commit `b82d85f`): `func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*models.User, error)` reads `Authorization: Bearer` first, then cookies `var sessionCookieNames = []string{"token", "auth_token"}`. It checks the JWT secret, the blacklist and `tokens_valid_after`, and excludes soft-deleted users. [VERIFIED: classes/session_user.go:21, 34-79]
|
||||
|
||||
**cooler** [VERIFIED: modules/cooler/cooler.go:74-95, memory.go:113-139, rediscache.go:18-53]: the `Cache` interface has `Get, Set, Has, Forget, Remember, Increment, Flush`. Memory is otter v2 (`m.c.Compute(…)` with `otter.ComputeOp` already used by `Increment`). Redis is go-redis v9.23.0, whose `GetDel` is documented "redis-server version >= 6.2.0" (string_commands.go:180-185). Keys are `<prefix>cache:<key>`. `miniredis v2.40.0` supports `GETDEL`, and the docker test uses `redis:7-alpine`. Local Redis is 7.2.5. The only implementations of `Cache` in the whole workspace are `memoryCache` and `redisCache`.
|
||||
|
||||
**bouncer cookie** [VERIFIED: modules/bouncer/cookie.go:15, 63, 145-159]: `const DefaultCookieName = "token"`, `func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error)`, `func (c CookieConfig) TokenCookie(token string, refreshTTL time.Duration) *http.Cookie`. Secure is inferred from `app.url` being https, and production refuses `Secure=false`.
|
||||
|
||||
**App key** [VERIFIED: modules/lagoon/encrypted.go:179]: `func LoadAppKey(cfg *compass.Config) ([]byte, [][]byte, error)` returns the 32-byte primary key and the previous keys. lagoon's own column key is `hkdf.Key(sha256.New, appKey, nil, "summercms.lagoon.encrypted.v1", 32)` (:45-49). No exported generic seal/open helper exists.
|
||||
|
||||
**Settings with secrets**: `cabana.SecretFallbacks` (`interface { SecretFallbacks() map[string]string }`, cabana/settings_secret.go:23-24) plus `type: password` fields on `lagoon.Encrypted` columns. These are write-only, and the admin page reports each secret's state as stored, env or empty. Analog: `sm-pgstripe-plugin/models/settings.go:55-105` (singleton row id 1, `LoadSettings(ctx, db)` per request, `SecretFallbacks` mapping columns to config keys). This matches D-16 more closely than paymentgateway.
|
||||
|
||||
**Throttles** [VERIFIED: surf/limiter.go:148-182, user plugin plugin.go:148-167]: inline `throttle:20,1` parses as `n,minutes`, keyed `"inline:domainless|"+ClientIP` (or the user id when a principal exists). The `user-api` bucket is registered by the user plugin's `Buckets()` (120/min, keyed by bearer `sub` or IP), so sociallogin routes can use `surf.Use("throttle:user-api")`.
|
||||
|
||||
**Service registry** [VERIFIED: backpack/services.go:22-40, user plugin.go:93]: `Publish[T]` keys by `reflect.TypeFor[T]()` and refuses duplicates. Publishing under an interface needs an explicit type argument, as in `app.Publish[bouncer.BlacklistStore](bl)`.
|
||||
|
||||
**Legacy env mapping precedent:** `sm-herald-plugin/boot.go:49-63` `copyEnv(key, "VAPID_PUBLIC_KEY")` copies PHP env names onto empty config keys at Boot. Use the same for `GOOGLE_CLIENT_ID` and the rest, plus `BETATESTER_KEY`.
|
||||
|
||||
### Q6. The 19.4 analog (`sm-oauthserver-plugin`)
|
||||
|
||||
[VERIFIED: files read this session]
|
||||
- Repo at `summercms/sm-oauthserver-plugin`, a sibling of `summercms.go`, with remote `git@git.golem15.com:golem15/sm-oauthserver-plugin.git`. Not yet mounted in any app.
|
||||
- `go.mod`: `module git.golem15.com/golem15/sm-oauthserver-plugin`, `require git.golem15.com/golem15/sm-user-plugin v0.1.1`, `git.golem15.com/golem15/summercms v0.2.4`, plus `replace git.golem15.com/golem15/summercms => ../summercms.go` (go.mod:150).
|
||||
- `go.work`: `use .`, `replace git.golem15.com/golem15/summercms => ../summercms.go`, `replace git.golem15.com/golem15/sm-user-plugin => ../fonoteka.go/plugins/golem15/user`.
|
||||
- `plugin.go`: `const PluginID = "golem15.oauthserver"`, `func (p *Plugin) Requires() []string { return []string{"golem15.user"} }`, `//go:embed config`, `lang`, `views`. Boot calls `LoadConfig`, `app.Publish(servers)`, and subscribes to `userclasses.DeletingEvent`/`DeactivatingEvent`. `init() { party.Register(&Plugin{}) }`. Interface assertions are `var _ pact.HasRoutes = (*Plugin)(nil)` and so on.
|
||||
- Tests: `testmain_test.go` starts one Postgres container. `freshDB(t)` skips under `testing.Short()` and creates a fresh database per test (`CREATE DATABASE … TEMPLATE template0`, dropped `WITH (FORCE)`). `harness_test.go` `tryBootOAuth` writes `app.yaml`, opens compass with env (`SUMMER_APP__KEY`, `SUMMER_GOLEM15__USER__JWT__SECRET`, …), calls `lagoon.PublishEncryptionKeys`, `lagoon.Publish`, `party.Activate(app, []string{"golem15.user", PluginID})`, `lagoon.Migrate` and serves an `httptest` server. **Copy this harness** for sociallogin.
|
||||
|
||||
### Q7. sm-demo-app and sm-bm-app
|
||||
|
||||
- **Mounting** [VERIFIED: summer.yaml, go.work, go.mod of each app]: `summer.yaml` lists `- id: golem15.user` / `module: git.golem15.com/golem15/sm-user-plugin`. Submodules live at `plugins/golem15/<name>`, each listed in `go.work` `use`. sm-demo-app's `go.work` also has `replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user`. sociallogin needs a submodule at `plugins/golem15/sociallogin`, a `go.work use`, `summer.yaml` `- id: golem15.sociallogin` / `module: git.golem15.com/golem15/sm-sociallogin-plugin`, and then `summer build` to regenerate `plugins.gen.go`.
|
||||
- **sm-demo-app** `boot_test.go` keeps `wantPlugins` in `summer.yaml` order and boots every plugin **without a database**, so sociallogin's Boot must not require a DB or cache. Use `lagoon.OnDatabase` or request-time lookups. `config/app.yaml`: `url: http://127.0.0.1:8097`. The demo frontend `vue-demo-app` has `login.vue`/`useDemoAuth.ts`; D-21 only requires mounting plus config (providers stay disabled without credentials).
|
||||
- **sm-bm-app** frontend [VERIFIED: frontend/src/*, plugins/jz/bm/static.go]:
|
||||
- The frontend is in `sm-bm-app/frontend/` (Vue 3 + vite + vue-router `createWebHistory('/')`, Polish routes `/logowanie`, `/rejestracja`). Its build output is embedded from `plugins/jz/bm/public` (`//go:embed all:public`) and served by the same binary with an SPA fallback.
|
||||
- `reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/api", "/storage"}` (static.go:42). **Add `"/oauth"`.**
|
||||
- The token lives in `localStorage`/`sessionStorage` under `bm.token` and is sent as `Authorization: Bearer` (api/http.ts:25-117), with no cookie.
|
||||
- The callback wiring needs a `/auth/oauth/callback` route (or a configured Polish path such as `/logowanie/oauth`, since `callback_path` is configurable under D-17), a Microsoft button on `LoginView.vue`, and a pending-consent view. `config/golem15.user.yaml` has `registration: allow_registration: true`, and `config/app.yaml` has `locale: pl`.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/markbates/goth` (core + `providers/google`, `facebook`, `github`, `azureadv2`) | v1.82.0 | Provider endpoints, code exchange, profile normalisation | Locked by D-02. Under 300 LOC per provider, MIT. [VERIFIED: proxy + source] |
|
||||
| `golang.org/x/oauth2` | MVS picks the app's (v0.36.0 in demo, v0.37.0 in oauthserver); goth needs ≥ v0.27.0 | Token exchange, `RetrieveError` | Go-team module, already in the workspace graph [VERIFIED] |
|
||||
| stdlib `crypto/aes`, `crypto/cipher` (GCM), `crypto/hkdf`, `crypto/rand`, `crypto/subtle`, `encoding/base64` | Go 1.27 | Cookie sealing, state, codes | Standard library first (CLAUDE.md). `crypto/hkdf` is stdlib (already used by lagoon) |
|
||||
| `cooler.Cache.Pull` (new) | framework next minor | Atomic one-time codes | D-08 |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `github.com/redis/go-redis/v9` `GetDel` | v9.23.0 (framework) | Redis side of `Pull` | Redis ≥ 6.2 |
|
||||
| `github.com/maypok86/otter/v2` `Compute`/`InvalidateOp` | v2.3.0 (framework) | Memory side of `Pull` | Default driver |
|
||||
| testcontainers Postgres | v0.44.0 (already used) | DB-backed tests | Full suite |
|
||||
| `github.com/alicebob/miniredis/v2` | v2.40.0 (framework test dep) | `GETDEL` tests | cooler unit tests |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| goth wrappers | `golang.org/x/oauth2` + hand-written profile calls | Fewer deps, but the user prefers a maintained library for protocol code. Locked by D-02 |
|
||||
| `azureadv2` | `microsoftonline` | Hard-coded `common` tenant, extra dep. Rejected |
|
||||
| stdlib AEAD cookie | `lagoon.NewEncrypted(...).Value()` / `(*lagoon.Encrypted).Scan` | Zero new code, but it reuses the DB column key domain (`summercms.lagoon.encrypted.v1`). A domain-separated HKDF label is the cleaner choice |
|
||||
| stdlib AEAD cookie | `gorilla/securecookie` | New dependency for about 30 lines of stdlib. Rejected |
|
||||
|
||||
**Installation** (inside `sm-sociallogin-plugin`, only after the decision note exists and the human package check passes):
|
||||
```bash
|
||||
go get github.com/markbates/goth@v1.82.0
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
The package-legitimacy seam supports only `npm|pypi|crates` (`msd-tools: Usage: … --ecosystem <npm|pypi|crates>`). Go modules cannot be checked by it, which is the same situation as 19.4 (`.planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md`: "the package-legitimacy seam cannot check Go modules"). Signals were gathered by hand:
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| github.com/markbates/goth | proxy.golang.org | v1.82.0 tagged 2025-08-18; repo has 825 commits | n/a (Go). 6.6k GitHub stars, 629 forks | github.com/markbates/goth (MIT) | seam N/A; govulncheck clean; [ASSUMED] for legitimacy per the provenance rule | **Planner adds `checkpoint:human-verify` before `go get`** (19.4 precedent) |
|
||||
| golang.org/x/oauth2 | proxy.golang.org | Go-team module, already in workspace graph | — | go.googlesource.com/oauth2 | already approved (19.4 note) | Transitive, no action |
|
||||
| cloud.google.com/go/compute/metadata | proxy.golang.org | already in workspace graph (v0.9.0 selected in demo) | — | github.com/googleapis/google-cloud-go | already present | Transitive, no action |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none. goth is gated by a human check because the seam cannot evaluate Go and the project shows maintainer-search signals.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
SPA (Horoskopia / bm / demo) sociallogin plugin Provider (Google/FB/GitHub/Entra)
|
||||
───────────────────────────── ────────────────── ─────────────────────────────────
|
||||
click "Sign in with X"
|
||||
│ top-level GET /oauth/{p}?mode=spa&action=…&frontend_callback=…&return_to=…[&consent…]
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ throttle:20,1 → registry: name registered & │──unknown name──► 404
|
||||
│ enabled (settings ⊕ config)? │──disabled──────► 302 {callback}?error=provider_not_configured
|
||||
│ normalize callback (D-17) + return_to │
|
||||
│ action=link? RequestUser(bearer|token cookie)│──none──────────► 302 {callback}?error=must_be_logged_in
|
||||
│ state=rand; goth.BeginAuth(state) │
|
||||
│ Set-Cookie: sealed{state,ctx,consent,sess} │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ 302 authURL ────────────────────────────────────────────────────────► user authenticates
|
||||
│ │
|
||||
GET /oauth/{p}/callback?code&state ◄────────────────────────────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ open+clear cookie; constant-time state cmp │──fail──► 302 {callback}?error=invalid_state
|
||||
│ goth Unmarshal → Authorize(code) → FetchUser │──invalid_grant──► ?error=code_expired
|
||||
│ Provider wrapper → Profile{ID,Email,Verified} │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐ identity (provider,provider_id) ──► login
|
||||
│ match (D-10/D-11) via user-plugin helpers │ no email ─────────────────────────► pending(fb_no_email)
|
||||
│ DB tx: upsert identity / create user │ email match & !verified ─────────► pending(unverified_match)
|
||||
│ │ email match & verified ──────────► link + login
|
||||
│ │ none & action=login ─────────────► pending()
|
||||
│ │ none & action=register ──────────► create user (consent ≤600s)
|
||||
└───────┬───────────────────────────────┬──────┘
|
||||
│ cooler.Set oauth-complete:{c} │ cooler.Set oauth-pending-registration:{c}
|
||||
▼ ▼
|
||||
302 {callback}?code=c 302 {callback}?pending_registration=c[&reason][&provider][&email]
|
||||
│ │
|
||||
SPA: GET /_user/api/v1/oauth-complete SPA consent page: POST /_user/api/v1/oauth-register-complete
|
||||
│ cooler.Pull → 200 {token,user,action,return_to} │ Get → validate consent (422 keeps code)
|
||||
│ (D-09 flag: Set-Cookie token, no token in body) │ → Pull → create/login → 200 / 410 / 422
|
||||
▼ ▼
|
||||
SPA stores session, navigates to return_to
|
||||
|
||||
user plugin: GET /_user/api/v1/oauth-providers ──app.Lookup[classes.OAuthProviderSource]──► sociallogin (enabled list)
|
||||
host app: DELETE …/oauth-identities/{p} ──► user plugin: tx{ SELECT users FOR UPDATE; guarded DELETE }
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
sm-sociallogin-plugin/ # git.golem15.com/golem15/sm-sociallogin-plugin, package sociallogin
|
||||
├── plugin.go # PluginID "golem15.sociallogin", Requires golem15.user, Register/Boot, interface asserts
|
||||
├── routes.go # /oauth group (throttle:20,1) + /_user/api/v1 oauth-complete|oauth-register-complete
|
||||
├── config.go # LoadConfig: frontend_url, callback_path, allowed_hosts, token_cookie, beta_key, timeouts
|
||||
├── admin_settings.go # pact.HasSettings (pgstripe-style singleton, SecretFallbacks)
|
||||
├── classes/
|
||||
│ ├── provider.go # exported Provider, Profile, ProviderConfig, Registry, Register func
|
||||
│ ├── providers_goth.go # google/facebook/github/microsoft wrappers (only file importing goth)
|
||||
│ ├── statecookie.go # seal/open (AES-GCM, HKDF label), size bound
|
||||
│ ├── callback.go # normalizeFrontendCallback / normalizeReturnTo
|
||||
│ ├── matcher.go # D-10 branches, linking, pending payload
|
||||
│ └── codes.go # 64-char codes, cooler Set/Pull wrappers
|
||||
├── controllers/ # redirect, callback, complete, register-complete handlers
|
||||
├── models/settings.go + models/settings/fields.yaml
|
||||
├── updates/ # golem15_sociallogin_settings migration (+ seed row 1)
|
||||
├── config/config.yaml lang/{en,pl}/lang.yaml
|
||||
├── README.md go.mod go.work
|
||||
└── *_test.go # harness_test.go/testmain_test.go copied from oauthserver
|
||||
```
|
||||
|
||||
### Pattern 1: Provider registry of factories, built per request
|
||||
**What:** The registry stores `name → factory`. A factory receives the **current** credentials (settings row ⊕ config), builds a fresh provider (goth `New` only fills an `oauth2.Config`, so this is cheap) and returns the plugin-owned interface. Changed settings take effect on the next request without a restart (D-16), and tests register fakes through the same registry (D-03).
|
||||
**When to use:** all four built-ins, plus app-registered providers.
|
||||
**Shape** (identifiers are Claude's discretion):
|
||||
```go
|
||||
// classes/provider.go — no goth import here.
|
||||
type Profile struct {
|
||||
ID, Email, Name, AvatarURL string
|
||||
EmailVerified bool // decided by the provider wrapper (D-11)
|
||||
AccessToken, RefreshToken string
|
||||
ExpiresAt time.Time // zero = none
|
||||
Raw map[string]any
|
||||
}
|
||||
type ProviderConfig struct{ ClientID, ClientSecret, RedirectURL, Tenant string; HTTPClient *http.Client }
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Label() string
|
||||
// Begin returns the authorization URL and an opaque session the plugin seals in its cookie.
|
||||
Begin(state string) (authURL, session string, err error)
|
||||
// Complete exchanges the callback parameters and returns the profile.
|
||||
Complete(ctx context.Context, session string, callback url.Values) (Profile, error)
|
||||
}
|
||||
type Factory func(ProviderConfig) (Provider, error)
|
||||
type Registry struct{ /* mu, order []string, factories map[string]Factory, labels */ }
|
||||
func (r *Registry) Register(name, label string, f Factory) error // rejects reserved names (Pitfall 9) and duplicates
|
||||
```
|
||||
Publish the `*Registry` on the app in `Register` (before dependents boot), so another plugin that `Requires("golem15.sociallogin")` can `app.Lookup[*classes.Registry]()` and add a provider.
|
||||
|
||||
### Pattern 2: Sealed state cookie
|
||||
Seal and open with AES-256-GCM under `hkdf.Key(sha256.New, appKey, nil, "golem15.sociallogin.oauth-state.v1", 32)`. Use the cookie name as AAD. Add a version byte and an `iat` inside, and reject anything older than the TTL even if the browser still sends it. Bound the plaintext (for example `return_to` ≤ 1024 bytes, total sealed ≤ 3800 bytes). Cookie: `HttpOnly`, `SameSite=Lax`, `Path=/oauth`, `MaxAge=600`, `Secure` = `bouncer.LoadCookieConfig(app.Config).Secure`. Consider one cookie name per provider (`summer_oauth_<p>`) so two tabs on different providers don't clobber each other. PHP shared one session key, so this is discretionary.
|
||||
|
||||
### Pattern 3: Provider list through the service registry (D-15)
|
||||
sm-user-plugin declares the interface, because sociallogin imports the user plugin and not the reverse:
|
||||
```go
|
||||
// sm-user-plugin classes/oauth_providers.go (additive)
|
||||
type OAuthProvider struct{ Name, Label string }
|
||||
type OAuthProviderSource interface {
|
||||
EnabledOAuthProviders(ctx context.Context) []OAuthProvider // enabled only, registry order
|
||||
KnownOAuthProviders() []string // for unlink's allow-list
|
||||
}
|
||||
```
|
||||
sociallogin Boot: `app.Publish[userclasses.OAuthProviderSource](svc)`. The **explicit type argument** is required (Pitfall 7). `OAuthProviders` does the lookup **per request**, because the user plugin boots first.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Importing `gothic` or `goth.UseProviders`.** gothic drags in gorilla/sessions, and `goth.UseProviders` is a process-global map that breaks parallel tests and multi-app processes. Keep providers in the plugin registry.
|
||||
- **Echoing the input `frontend_callback`.** Always rebuild `scheme://host[:port]` + the configured path from parsed parts.
|
||||
- **Get, then validate, then Forget** for codes. Use `Pull` as the single consume step.
|
||||
- **Count-then-delete or one guarded DELETE without a lock** for unlink (Pitfall 1).
|
||||
- **Putting the JWT in the redirect URL** (`?token=`). Only the 64-char one-time code goes in URLs.
|
||||
- **Logging `Profile.AccessToken`, `RefreshToken`, the client secret or the full `RetrieveError.Body`.**
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| OAuth code exchange, token parsing | `net/http` POST + JSON parsing | goth providers on `golang.org/x/oauth2` | Auth-style detection (header vs body), `RetrieveError` parsing, expiry |
|
||||
| Provider endpoint URLs and profile mapping | own constants | goth packages (`google.Endpoint`, `azureadv2` tenant templates, Graph `/me`) | Already correct and maintained |
|
||||
| Authenticated encryption | XOR/HMAC-only/CBC schemes | stdlib `cipher.NewGCM` + `crypto/hkdf` | AEAD with a fresh nonce is the standard construction |
|
||||
| Constant-time compare | `==` on state/beta key | `crypto/subtle.ConstantTimeCompare` (or `hmac.Equal`) | Timing side channel (D-12) |
|
||||
| Random codes | `math/rand` | `crypto/rand` (`rand.Read` 48 bytes → `base64.RawURLEncoding` = 64 chars) | Unpredictability |
|
||||
| Get-and-delete | Lua script or MULTI | Redis `GETDEL` / otter `Compute` + `InvalidateOp` | Atomic primitives exist |
|
||||
| JWT verification for link | parsing the cookie yourself | `userclasses.RequestUser` | Blacklist and `tokens_valid_after` handled |
|
||||
| Encrypted secret settings | own encryption of client secrets | `lagoon.Encrypted` columns + `cabana.SecretFallbacks` | Write-only admin UX already exists |
|
||||
|
||||
**Key insight:** the protocol code is the risky part, and goth plus x/oauth2 cover it. Everything the plugin must still own (state binding, redirect allow-listing, matching) is policy, and the tests cover it with fake providers.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: PHP's "atomic" guarded DELETE races on Postgres (D-13)
|
||||
**What goes wrong:** Two concurrent unlinks (google, facebook) on a password-less two-identity user both delete, leaving zero identities and locking the user out.
|
||||
**Why it happens:** Under READ COMMITTED, each statement's `COUNT(*)` subquery sees its own snapshot (count = 2), and the two DELETEs touch different rows, so neither waits for the other. The PHP comment says the opposite. Its test (`test_concurrent_unlink_cannot_strip_last_identity`) runs the two unlinks sequentially.
|
||||
**Evidence** [VERIFIED: throwaway `postgres:16` container, this session]: session A `BEGIN; <guarded DELETE google>; pg_sleep(3); COMMIT;` while session B runs `<guarded DELETE facebook>` gave `B: DELETE 1`, `A: DELETE 1`, remaining identities `(0 rows)`. With `SELECT id FROM users WHERE id=1 FOR UPDATE` first in both transactions: `A: DELETE 1`, `B: DELETE 0`, one `facebook` row left.
|
||||
**How to avoid:** `OAuthIdentitiesDestroy` runs `db.Transaction(func(tx) { tx.Exec("SELECT id FROM users WHERE id = ? FOR UPDATE", uid); res := tx.Exec(guardedDelete, uid, provider); … })`. `RowsAffected == 0` after confirming the row exists maps to 409 `last_method_blocked`. The linking and creation paths that add identities should take the same lock.
|
||||
**Warning signs:** a test that runs the two deletes with goroutines and no lock passes only by chance. Make the test deterministic with two explicit transactions, holding the first open.
|
||||
|
||||
### Pitfall 2: Columns D-10 names do not exist in Go
|
||||
**What goes wrong:** The plan writes `password_bootstrap_source`, `username`, consent columns, a consent audit or a default group, and none of them exist in the Go schema (Q5), so it fails at migrate or compile time.
|
||||
**How to avoid:** Decide Open Question 1 first. `RegisterUser` also needs a way to create with `has_self_set_password=false`: add `RegisterOptions.PasswordBootstrap bool` (or set it in the same transaction after create). Pass a 32-byte random password plus confirmation to satisfy the `password` rule.
|
||||
|
||||
### Pitfall 3: GitHub email via goth is not Socialite's
|
||||
**What goes wrong:** (a) goth `github.New` defaults to **no scopes**, so it never calls `/user/emails` (github.go:143-153, 217-233). (b) When the public profile email is set, goth returns it **without** checking primary or verified. (c) When no primary+verified email exists, `FetchUser` **returns an error** (`ErrNoVerifiedGitHubPrimaryEmail`, github.go:37, :214) instead of an empty email, so the flow would fail as `provider_error` instead of pending `fb_no_email`.
|
||||
**How to avoid:** Request scopes `read:user`, `user:email`. In the wrapper, call `https://api.github.com/user/emails` with the token and pick `primary && verified` (or ignore goth's email). On `ErrNoVerifiedGitHubPrimaryEmail`, return an empty email. Then `EmailVerified=true` holds by construction, which is D-11's PHP rationale (SocialAuth.php:903-911).
|
||||
|
||||
### Pitfall 4: Google verified flag and scopes
|
||||
**What goes wrong:** goth Google calls the **v2** userinfo endpoint `https://www.googleapis.com/oauth2/v2/userinfo` (google.go:17), whose field is **`verified_email`** (boolean). It does not return `email_verified`. A port that checks only `email_verified` treats every Google user as unverified. goth's default scope is only `"email"` (google.go:150-152), so `name` and `picture` come back empty.
|
||||
**How to avoid:** `EmailVerified = raw["verified_email"] == true || raw["email_verified"] == true`, with strict bool type assertions. Scopes `openid`, `email`, `profile`. `SetPrompt("select_account")`. Note that goth sets `AccessTypeOffline` by default (google.go:31-33). [VERIFIED: Google discovery doc oauth2/v2 `Userinfo.verified_email` "Boolean flag which is true if the email address is verified."]
|
||||
|
||||
### Pitfall 5: Microsoft provider name, scopes, avatar, email source
|
||||
`azureadv2` names itself `"azureadv2"`, omits `offline_access` by default, sets an auth-only Graph photo URL as `AvatarURL`, and takes email from Graph `mail` (often empty for personal accounts). Fix all four in the wrapper (Q2). `EmailVerified` is always false (D-11).
|
||||
|
||||
### Pitfall 6: Facebook defaults
|
||||
`facebook.New` asks Graph for `email,first_name,last_name,link,about,id,name,picture,location` on unversioned URLs (facebook.go:22-41). `about`/`location`/`link` need extra permissions. Use `SetCustomFields([]string{"id","name","email","first_name","last_name","picture"})`. goth already sends `appsecret_proof` (facebook.go:109-113). It cannot pin a Graph version (the URLs are constants), so the API version follows the Facebook app's dashboard default. **Recommendation for the discretion item: don't pin it in config.**
|
||||
|
||||
### Pitfall 7: Service published under the wrong type
|
||||
`app.Publish(svc)` keys by the concrete type, so `app.Lookup[classes.OAuthProviderSource]()` misses it and `oauth-providers` silently returns `[]`. Use `app.Publish[userclasses.OAuthProviderSource](svc)` (precedent: user plugin.go:93) and test the lookup through the real boot harness.
|
||||
|
||||
### Pitfall 8: Boot order and no DB at Boot
|
||||
sociallogin boots after the user plugin, and sm-demo-app's `TestBootAllPlugins` boots with no DB. Resolve DB, cache and settings at request time (or in `lagoon.OnDatabase`), never in `Boot`.
|
||||
|
||||
### Pitfall 9: `/oauth/{provider}` shadows other `/oauth/*` GETs, and provider names must avoid them
|
||||
[VERIFIED: ServeMux probe with oauthserver's patterns + sociallogin's.] There is no registration panic. Literal routes (`/oauth/jwks`, `/oauth/authorize`, `/oauth/mcp/authorize`) win. But `GET /oauth/token` (POST-only upstream) and `GET /oauth/mcp/callback` fall into the sociallogin handlers. **Unknown names must 404**, and `Registry.Register` should reject `authorize, authorizations, jwks, token, userinfo, mcp` and `.well-known`-like names.
|
||||
|
||||
### Pitfall 10: Email matching is case-sensitive on Postgres
|
||||
`LookupByEmail` uses `email = ?` and the column is `TEXT … UNIQUE` (case-sensitive). The PHP apps used MySQL with a case-insensitive collation [ASSUMED]. A Google `John@Gmail.com` would miss `john@gmail.com`, go pending, and then fail the users-email uniqueness check, or create a near-duplicate account. **Match with `lower(email) = lower(?)`** for OAuth matching.
|
||||
|
||||
### Pitfall 11: Soft-deleted users
|
||||
The identity lookup through the default GORM scope hides a deactivated user, while `LookupByEmail` is `Unscoped`. An OAuth login for a deactivated account can fall through to "create user" and then hit the email UNIQUE constraint. Decide the behaviour explicitly (Open Question 3).
|
||||
|
||||
### Pitfall 12: Memory cache on more than one instance
|
||||
D-07 makes the state cookie instance-independent, but the two codes live in `cooler`. With `cache.driver: memory` behind a load balancer, the SPA's `oauth-complete` can land on another instance and get 410. Document that multi-instance apps need `cache.driver: redis` (Redis ≥ 6.2 for `GETDEL`).
|
||||
|
||||
### Pitfall 13: Diverged sm-user-plugin history and concurrent sessions
|
||||
The 19.4 commits (`RequestUser`, …) are unpushed in fonoteka's checkout. 19.4 is still "verifying" (STATE.md), and 19.6 (2FA) will also modify sm-user-plugin. Sequence the work: merge `24b16b1` into the fonoteka line (one conflict hunk, Q5), push with the user's go-ahead, then build sociallogin against the pushed line. Leave other sessions' uncommitted edits alone.
|
||||
|
||||
### Pitfall 14: The beta gate is bypassable through the pending path (PHP bug)
|
||||
PHP validates `beta_key` only in `handleOAuthRegistration`, the `action=register` path (SocialAuth.php:495-505). `completePendingRegistration` never checks it, so `action=login` → pending → `oauth-register-complete` creates accounts with the gate on. **Carry `beta_key` from the start request through the cookie into the pending payload, and check it in constant time at completion too.**
|
||||
|
||||
### Pitfall 15: Consent `600 s` vs cookie lifetime
|
||||
The consent timestamp check (≤600 s) and a 10-minute cookie are the same window, and a slow provider login can just expire. Keep the cookie Max-Age at 600 s and check consent age at the callback, as PHP does. The error is `consent_expired`.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### 1. goth without gothic inside a wrapper (Google)
|
||||
```go
|
||||
// Source: goth@v1.82.0 providers/google/{google.go,session.go}; gothic/gothic.go:184-237 (flow reimplemented)
|
||||
func newGoogle(cfg classes.ProviderConfig) (classes.Provider, error) {
|
||||
gp := google.New(cfg.ClientID, cfg.ClientSecret, cfg.RedirectURL, "openid", "email", "profile")
|
||||
gp.SetPrompt("select_account")
|
||||
gp.HTTPClient = cfg.HTTPClient // has a Timeout; goth falls back to http.DefaultClient otherwise
|
||||
return &gothProvider{name: "google", label: "Google", gp: gp, verified: func(u goth.User) bool {
|
||||
v1, _ := u.RawData["verified_email"].(bool)
|
||||
v2, _ := u.RawData["email_verified"].(bool)
|
||||
return v1 || v2
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (p *gothProvider) Begin(state string) (string, string, error) {
|
||||
sess, err := p.gp.BeginAuth(state)
|
||||
if err != nil { return "", "", err }
|
||||
u, err := sess.GetAuthURL()
|
||||
if err != nil { return "", "", err }
|
||||
return u, sess.Marshal(), nil
|
||||
}
|
||||
|
||||
func (p *gothProvider) Complete(ctx context.Context, blob string, cb url.Values) (classes.Profile, error) {
|
||||
sess, err := p.gp.UnmarshalSession(blob)
|
||||
if err != nil { return classes.Profile{}, err }
|
||||
if _, err := sess.Authorize(p.gp, cb); err != nil { return classes.Profile{}, err } // *oauth2.RetrieveError on invalid_grant
|
||||
u, err := p.gp.FetchUser(sess)
|
||||
if err != nil { return classes.Profile{}, err }
|
||||
return classes.Profile{ID: u.UserID, Email: u.Email, Name: u.Name, AvatarURL: u.AvatarURL,
|
||||
EmailVerified: p.verified(u), AccessToken: u.AccessToken, RefreshToken: u.RefreshToken,
|
||||
ExpiresAt: u.ExpiresAt, Raw: u.RawData}, nil
|
||||
}
|
||||
// Microsoft: azureadv2.New(id, secret, cb, azureadv2.ProviderOptions{Tenant: azureadv2.TenantType(tenant),
|
||||
// Scopes: []azureadv2.ScopeType{azureadv2.OpenIDScope, azureadv2.ProfileScope, azureadv2.EmailScope,
|
||||
// azureadv2.OfflineAccessScope, azureadv2.UserReadScope}}); verified always false.
|
||||
```
|
||||
(`OpenIDScope ScopeType = "openid"` :30, `EmailScope ScopeType = "email"` :36, `ProfileScope ScopeType = "profile"` :43, `OfflineAccessScope ScopeType = "offline_access"` :57, `UserReadScope ScopeType = "User.Read"` :670, all in `providers/azureadv2/scopes.go`.)
|
||||
|
||||
### 2. Seal and open the state cookie (stdlib)
|
||||
```go
|
||||
// Source: stdlib crypto/hkdf (as lagoon/encrypted.go:45-49 uses it), crypto/cipher GCM
|
||||
const stateKeyInfo = "golem15.sociallogin.oauth-state.v1"
|
||||
|
||||
func stateAEAD(app *backpack.App) (cipher.AEAD, error) {
|
||||
primary, _, err := lagoon.LoadAppKey(app.Config) // 32 bytes or error naming app.key
|
||||
if err != nil { return nil, err }
|
||||
key, err := hkdf.Key(sha256.New, primary, nil, stateKeyInfo, 32)
|
||||
if err != nil { return nil, err }
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil { return nil, err }
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func seal(aead cipher.AEAD, cookieName string, plain []byte) (string, error) {
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil { return "", err }
|
||||
return base64.RawURLEncoding.EncodeToString(aead.Seal(nonce, nonce, plain, []byte(cookieName))), nil
|
||||
}
|
||||
|
||||
func open(aead cipher.AEAD, cookieName, value string) ([]byte, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil || len(raw) < aead.NonceSize() { return nil, errInvalidState }
|
||||
n := aead.NonceSize()
|
||||
return aead.Open(nil, raw[:n], raw[n:], []byte(cookieName)) // then JSON-decode, check v, iat ≤ 600 s
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `cooler.Cache.Pull`
|
||||
```go
|
||||
// Interface addition (modules/cooler/cooler.go):
|
||||
// // Pull returns the value at key and removes it in one atomic step;
|
||||
// // found is false when the key is missing or expired.
|
||||
// Pull(ctx context.Context, key string) ([]byte, bool, error)
|
||||
|
||||
// Redis (rediscache.go) — go-redis v9 GetDel, Redis >= 6.2:
|
||||
func (r *redisCache) Pull(ctx context.Context, key string) ([]byte, bool, error) {
|
||||
if err := r.ready(); err != nil { return nil, false, err }
|
||||
b, err := r.client.GetDel(ctx, r.key(key)).Bytes()
|
||||
if errors.Is(err, redis.Nil) { return nil, false, nil }
|
||||
if err != nil { return nil, false, err }
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Memory (memory.go) — otter Compute runs under the per-key lock (like Increment):
|
||||
func (m *memoryCache) Pull(_ context.Context, key string) ([]byte, bool, error) {
|
||||
var out []byte
|
||||
found := false
|
||||
now := time.Now()
|
||||
m.c.Compute(m.key(key), func(old memEntry, ok bool) (memEntry, otter.ComputeOp) {
|
||||
if !ok { return old, otter.CancelOp }
|
||||
if !old.expired(now) { out, found = clone(old.val), true }
|
||||
return memEntry{}, otter.InvalidateOp
|
||||
})
|
||||
return out, found, nil
|
||||
}
|
||||
```
|
||||
Test it with N goroutines pulling one key: exactly one gets `found=true` (both drivers, miniredis + `redis:7-alpine`).
|
||||
|
||||
### 4. Callback normalisation (PHP :1185-1251 plus D-17)
|
||||
```go
|
||||
func normalizeFrontendCallback(raw string, allowed hostSet, path string) (string, bool) {
|
||||
if raw == "" { return "", false }
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Path != path { return "", false }
|
||||
if u.Scheme == "" && u.Host == "" { return path, true } // relative, as PHP returns the constant path
|
||||
if u.Scheme != "http" && u.Scheme != "https" || u.Host == "" { return "", false }
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if !allowed.has(host) { return "", false } // localhost, 127.0.0.1, request host, app.url host, frontend_url host, allowed_hosts
|
||||
return u.Scheme + "://" + u.Host + path, true // rebuilt: no userinfo, query, fragment
|
||||
}
|
||||
|
||||
func normalizeReturnTo(s string) string {
|
||||
if s == "" || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") ||
|
||||
strings.HasPrefix(s, "/\\") || strings.ContainsAny(s, "\\\r\n\t") || len(s) > 1024 {
|
||||
return "/"
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
The backslash, control-character and length checks are stricter than PHP (A5).
|
||||
|
||||
### 5. Race-safe unlink (D-13)
|
||||
```go
|
||||
// Source: measured behaviour (Pitfall 1); WHERE clause ported from PHP User.php:913-932
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, uid).Error; err != nil { return err }
|
||||
res := tx.Exec(`DELETE FROM golem15_user_oauth_identities
|
||||
WHERE user_id = ? AND provider = ?
|
||||
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
|
||||
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`,
|
||||
uid, provider)
|
||||
if res.Error != nil { return res.Error }
|
||||
deleted = res.RowsAffected
|
||||
return nil
|
||||
})
|
||||
// existing row + deleted == 0 → 409 last_method_blocked (unchanged body); deleted == 1 → 204
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `state` only for CSRF | RFC 9700 (OAuth 2.0 Security BCP, Jan 2025): "For confidential clients, the use of PKCE is RECOMMENDED"; without PKCE, "one-time use CSRF tokens carried in the `state` parameter" must be used | 2025 | goth's Google/FB/GitHub/azureadv2 providers expose no PKCE hook (the `oauth2.Config` is unexported). The plugin relies on a browser-bound one-time `state` (D-07), which meets the BCP's requirement without PKCE. PKCE goes on the deferred list [CITED: rfc-editor.org/rfc/rfc9700 §2.1, §2.1.1] |
|
||||
| Email claim as identity | `oid`/`tid`/`sub` as identity, email treated as unverified for Entra | nOAuth disclosures (2023+) | D-11 Microsoft is always unverified. The identity key is Graph `/me` `id` [CITED: third-party write-ups summarised in search; Microsoft Q&A quote] |
|
||||
| Socialite (PHP) GitHub email = primary+verified | goth GitHub = public email first | — | Pitfall 3 |
|
||||
| Google OIDC userinfo v3 `email_verified` | goth uses v2 `verified_email` | — | Pitfall 4 |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `gothic` with the gorilla/sessions store, which D-02 excludes.
|
||||
- goth `providers/azuread` (v1 endpoint, `graph.windows.net`) and `microsoftonline` (no tenant). Use `azureadv2`.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | goth's legitimacy (the seam can't check Go). Treated as needing a human check | Package Legitimacy Audit | Low: well-known library, but the gate stays |
|
||||
| A2 | The PHP apps (wavepath/Horoskopia) ran MySQL with case-insensitive email matching | Pitfall 10 | If they were case-sensitive, lower() matching is still safer and only merges verified emails |
|
||||
| A3 | Graph `/me` `id` is stable and globally unique enough as `provider_id` for `common` tenants (GUID object ids; 16-hex ids for personal accounts) | Q2 | A cross-tenant collision would need identical GUIDs: negligible. Using `tid:oid` would need ID-token parsing goth doesn't keep |
|
||||
| A4 | GitHub only lets users publish a verified address as the public email | Pitfall 3 | Irrelevant if the wrapper always re-reads `/user/emails` as recommended |
|
||||
| A5 | Rejecting `/\…`, control characters and >1024-byte `return_to` breaks no client | Code Example 4 | A client passing such paths would land on `/` |
|
||||
| A6 | The PHP pending 422 text stays hard-coded Polish, or becomes a pl lang key with a new en text | Q4 | Horoskopia shows `error`. An en text is a new string, not parity |
|
||||
| A7 | Giving inactive/banned users real messages instead of PHP's raw missing keys is acceptable | Q4 table | The SPA shows the text only |
|
||||
| A8 | Microsoft personal accounts often have an empty Graph `mail` | Q2, Open Question 5 | More `fb_no_email` pending outcomes for Microsoft |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **D-10 fields that the Go schema lacks.** What we know: no `username`, `password_bootstrap_source`, consent columns, consent audit or `default_group` setting exists, and Go `/register` also ignores `terms_accepted`. What's unclear: whether 19.5 adds migrations to sm-user-plugin (an additive, core-plugin contract change) or maps onto what exists.
|
||||
**Recommendation:** map `username` to the existing `handle` (generated by `AssignHandle` inside `RegisterUser`, and from the email local part when the provider gives no name). Set `marketing_consent=true` when opted in. Pass consent (terms/privacy/marketing/versions/IP) in the `RegisterEvent.Payload` so apps can record it. Skip `default_group` (Go `/register` doesn't assign one either). Add only `password_bootstrap_source TEXT NULL` if the user wants the audit marker. **Ask the user.**
|
||||
2. **Login event.** Go has no `user.login` event. **Recommendation:** run `TouchLastSeen` and update `last_ip_address` (PHP `touchIpAddress`). Add no new event unless asked.
|
||||
3. **Soft-deleted accounts.** An OAuth login for a deactivated user (identity or email). **Recommendation:** mirror password `Login`, which restores (`deleted_at = nil`) and sends the reactivate mail, only for an identity match or a verified email match. Treat an unverified match as `unverified_match` as usual. Never create a second row for an existing (even trashed) email.
|
||||
4. **`action=link` for bearer-only SPAs (bm) and D-11 for bm.** What we know: a top-level navigation cannot send `Authorization`, so `RequestUser` sees nobody unless the app uses the token cookie. bm uses localStorage. Microsoft never auto-links, so every existing bm account with a matching email hits `unverified_match` and is told to "sign in and link", which bm cannot do. Options:
|
||||
- (a) a one-time **link ticket**: authenticated `POST /_user/api/v1/oauth-link-ticket` → 60 s code in `cooler`; the SPA navigates `/oauth/microsoft?action=link&link_ticket=…`; the plugin `Pull`s it and stores the uid in the state cookie. This is additive and keeps JWTs out of URLs.
|
||||
- (b) accept `?token=<jwt>` as tymon/jwt-auth's QueryString parser did in PHP (it leaks the JWT into URLs and proxy logs).
|
||||
- (c) bm switches to cookie transport.
|
||||
- (d) for bm only, treat Microsoft as verified when `tenant` is a specific tenant GUID (the school's own directory). This changes locked D-11 and needs the user.
|
||||
|
||||
**Recommendation:** (a), and raise (d) with the user as a bm-specific question.
|
||||
5. **Microsoft email source.** Fall back to `userPrincipalName` when Graph `mail` is empty and the UPN contains `@`? It is unverified either way. **Recommendation:** yes. It only pre-fills the pending screen and the new-user email, and any match is diverted.
|
||||
6. **Unlink relaxation in fonoteka.** fonoteka's own PHP (`OAuthIdentityApiController::destroy`) blocks the last identity *even with a password* (409). D-13 relaxes that for fonoteka too. **Recommendation:** accept it (CONTEXT calls it intended), or add `OAuthIdentitiesOptions.BlockLastIdentity bool` so fonoteka keeps byte parity. Confirm with the user.
|
||||
7. **Unknown or disabled provider at `/oauth/{p}`.** PHP: route regex → 404; not configured → ApplicationException error page. **Recommendation:** unknown → 404. Disabled, invalid action or link without a user → 302 to the resolved callback with `?error=` (more useful for SPAs; desktop shells only use enabled providers).
|
||||
8. **Framework version to pin.** `summercms.go` is 34 commits past `v0.2.4`. The `Pull` release tag (v0.2.5 or v0.3.0) needs the user's go-ahead, plus a changelog post the same day (CLAUDE.md).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go toolchain | all | ✓ | go1.27.2 | — |
|
||||
| Docker (testcontainers Postgres/Redis) | full `go test ./...` | ✓ (socket usable; `postgres:16` and `redis:7-alpine` usable) | 29.9.1 | `-short` skips DB tests |
|
||||
| Local Redis | manual `GETDEL` check | ✓ | 7.2.5 | miniredis in tests |
|
||||
| Node | bm frontend build/test | ✓ | v22.23.3 | — |
|
||||
| proxy.golang.org | `go get goth` | ✓ (downloads worked) | — | — |
|
||||
| Google/Facebook/GitHub/Microsoft app registrations + secrets | live UAT | ✗ (not in this environment) | — | Fake providers in tests; human UAT with real credentials; bm needs an Entra app registration with redirect `https://<bm-host>/oauth/microsoft/callback` |
|
||||
| git.golem15.com push + repo creation (`sm-sociallogin-plugin`) | delivery | needs the user's go-ahead | — | Work locally, push later |
|
||||
|
||||
**Missing dependencies with no fallback:** live provider credentials, for the human verification items only.
|
||||
**Missing dependencies with fallback:** none.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (go1.27.2) + `httptest`; testcontainers-go v0.44.0 Postgres (DB tests skip under `-short`); miniredis v2.40.0 and `redis:7-alpine` for cooler |
|
||||
| Config file | none (Go). New `sm-sociallogin-plugin/testmain_test.go` + `harness_test.go` copied from `sm-oauthserver-plugin` |
|
||||
| Quick run command | `cd sm-sociallogin-plugin && go test -short ./...`; `cd summercms.go && go test -short ./modules/cooler` |
|
||||
| Full suite command | `cd summercms.go && go vet ./... && go test ./modules/cooler ./...`; `cd <user-plugin checkout> && go test ./...`; `cd sm-sociallogin-plugin && go vet ./... && go test ./...`; app gates: `cd sm-demo-app && go test ./...`, `cd sm-bm-app && go test ./... && npm --prefix frontend run test && npm --prefix frontend run typecheck`, `cd fonoteka.go && go test ./...` |
|
||||
|
||||
### Phase Decisions → Test Map
|
||||
| Item | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|------|----------|-----------|-------------------|-------------|
|
||||
| D-08 | `Pull` returns+removes once; expired → not found; N concurrent pullers → exactly 1 winner (memory, miniredis, real Redis) | unit + docker | `go test ./modules/cooler -run 'TestPull'` | ❌ Wave 0 |
|
||||
| D-13 | password user unlinks last identity → 204; password-less last → 409 same body; two concurrent tx unlinks → 1 survives | integration (Postgres) | `go test ./... -run 'TestOAuthIdentitiesDestroy'` (user plugin) | ⚠ update existing `TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword`; new race test ❌ |
|
||||
| D-15 | no sociallogin → `{"success":true,"providers":[]}` unchanged; with fake enabled providers → `[{name,label}]` in order; disabled excluded | unit | user plugin `-run TestOAuthProviders`; sociallogin `-run TestProvidersList` | ❌ |
|
||||
| D-03 | fake provider registered through registry drives redirect → callback → code → complete end to end | integration | `go test ./... -run TestFlow` (sociallogin) | ❌ |
|
||||
| D-04/Pitfall 9 | unknown/reserved name → 404; disabled → `?error=`; registry rejects reserved names | unit | `-run 'TestRoutes|TestRegistry'` | ❌ |
|
||||
| D-07 | missing/tampered/expired/mismatched-state cookie → `?error=invalid_state`; cookie cleared on every outcome; attrs HttpOnly, Lax, Path=/oauth, Max-Age 600, Secure from app.url | unit | `-run TestStateCookie` | ❌ |
|
||||
| D-06/D-17 | callback normalisation table (relative, http/https, exact hosts incl. allowed_hosts, userinfo/query stripped, bad scheme, wrong path, `//host`) and return_to table | unit (table) | `-run 'TestNormalize'` | ❌ |
|
||||
| D-09 | complete: 422 empty, 410 unknown, 200 shape, second use 410; cookie flag → Set-Cookie token + no `token` key | integration | `-run TestOAuthComplete` | ❌ |
|
||||
| D-08/D-09 | register-complete: 422 consent keeps code (retry succeeds); 410 bad code; 422 `email_exists_link_account` on unverified match; concurrent double submit → one 200 | integration | `-run TestRegisterComplete` | ❌ |
|
||||
| D-10 | branch table: identity login; no email → `fb_no_email`; verified match → link; unverified → `unverified_match`; none+login → pending; none+register → created (consent ≤600 s, allow, throttle); inactive/banned refused | integration | `-run TestMatching` | ❌ |
|
||||
| D-11 | Google `verified_email` true/false/missing; GitHub true; Facebook false; Microsoft false even with `mail` = existing email (nOAuth case) | unit (wrapper with RoundTripper fakes) + integration | `-run 'TestEmailVerified|TestNOAuth'` | ❌ |
|
||||
| D-12 | beta gate off when empty; wrong key refused; checked on the pending path too | integration | `-run TestBetaGate` | ❌ |
|
||||
| D-14 | link resolves via bearer / `token` / `auth_token`; no JWT → `must_be_logged_in`; identity owned by another user → `account_already_linked` | integration | `-run TestLink` | ❌ |
|
||||
| D-16 | settings row overrides config; secret never in JSON/logs; enabled iff id+secret; change visible on next request | integration | `-run TestSettings` | ❌ |
|
||||
| D-19 | tokens stored encrypted with `token_expires_at`; avatar only in `profile_data.avatar` | integration | `-run TestIdentityTokens` | ❌ |
|
||||
| D-20 | route middleware lists `throttle:20,1` / `throttle:user-api` (route-info assertion like fonoteka `phase08_coverage_test.go`) | unit | `-run TestRouteMiddleware` | ❌ |
|
||||
| PHP map | `SocialAuthGateTest` (11), `OAuthPendingRegistrationTakeoverTest` (5), `OAuthPasswordBootstrapTest` unlink/bootstrap cases, `OAuthLinkCookieTest` (4), `ProfileOAuthLinkTest` (7): one Go test per PHP case, web-mode recursion cases mapped to "pending always has a callback" | integration | `-run TestPHP` | ❌ |
|
||||
| D-21 | demo boots with sociallogin (`TestBootAllPlugins` + `/oauth` routed); bm boots, `/oauth` reserved from SPA fallback, frontend callback view tests; fonoteka suite green at new pin | app tests | per-app commands above | ⚠ update existing |
|
||||
| Todo | `user:token:*` and `user:require-password-change` (+ `user:backfill-handles`) open the DB when none is published | unit | user plugin `-run TestConsole` (24b16b1 `console_test.go`) | ✅ after merge |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick command of the touched repo (`go test -short ./...`) plus `go vet ./...`.
|
||||
- **Per plan merge:** the full command of every touched repo (Docker).
|
||||
- **Phase gate:** full suites green in summercms.go, the user plugin, sociallogin, sm-demo-app, sm-bm-app (Go + frontend) and fonoteka.go before `/msd-verify-work`. Live-provider sign-in is human UAT.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `sm-sociallogin-plugin/testmain_test.go`, `harness_test.go` (copy of the oauthserver Postgres harness, booting `golem15.user` + `golem15.sociallogin`, memory cooler)
|
||||
- [ ] fake `classes.Provider` test double + `http.RoundTripper` fake for the goth wrappers
|
||||
- [ ] `modules/cooler` Pull tests (memory, miniredis, docker redis)
|
||||
- [ ] user-plugin race test with two explicit transactions (Pitfall 1)
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is absent from `.planning/config.json`, so it is treated as enabled. The target is ASVS Level 1, plus the D-11 security review of the matching and state code.
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | Provider-asserted identity keyed by `(provider, provider_id)`. Email auto-link only when verified (D-11). Inactive/banned gate. Register throttle and beta gate in constant time |
|
||||
| V3 Session Management | yes | One-time 64-char codes (≈384 bits) pulled atomically, TTL 5/10 min. JWT minted by `IssueToken`. Optional httpOnly token cookie via `bouncer.TokenCookie` (D-09). No JWT in URLs |
|
||||
| V4 Access Control | yes | `action=link` needs `RequestUser` (blacklist + `tokens_valid_after`). Unlink scoped to the caller with a row lock. Linking another user's identity refused |
|
||||
| V5 Input Validation | yes | Callback allow-list (exact host, fixed path, rebuilt URL). `return_to` normaliser. Provider name from the registry only. `laravelBool` parsing. Size bounds on cookie fields |
|
||||
| V6 Cryptography | yes | stdlib AES-256-GCM, HKDF-SHA256 from the app key with a purpose label. `crypto/rand`. `subtle.ConstantTimeCompare`. Client secrets in `lagoon.Encrypted` columns |
|
||||
| V7 Errors and Logging | yes | `?error=` carries only the localized human message. Never log tokens, secrets or `RetrieveError.Body`. The framework access log never records query strings (surf/accesslog.go:44-49) |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Login CSRF / callback replay (attacker's code in victim's browser) | Spoofing | Sealed per-browser state cookie, constant-time compare, empty state rejected, cookie cleared at callback (D-07) |
|
||||
| Open redirect via `frontend_callback` / `return_to` | Tampering | Exact-host allow-list, fixed path, URL rebuilt from parts; `return_to` must be a local path, with `//`, `/\` and control characters rejected |
|
||||
| Account takeover by unverified email (Facebook, Microsoft nOAuth, GitHub public email) | Elevation | D-11 table with wrapper-computed `EmailVerified`. `email_verified` carried in the pending payload, and its absence fails closed (PHP `empty(...)`) |
|
||||
| Pending-registration hijack | Elevation | Completion re-checks identity and email match. Unverified match → 422 `email_exists_link_account`, never login |
|
||||
| One-time code replay / race | Spoofing | `cooler.Pull` (GETDEL / locked invalidate), single winner test |
|
||||
| Unlink race locking a user out | DoS | `SELECT … FOR UPDATE` on `users` + guarded DELETE (measured) |
|
||||
| Beta gate bypass via pending path | Elevation | Beta key carried into the pending payload and checked at completion (Pitfall 14) |
|
||||
| Secret leakage (client secret, provider tokens) | Info disclosure | Encrypted columns, write-only settings, `json:"-"`. Pending payload holds provider tokens for ≤10 min in cache, as PHP did. Optionally seal the payload before `Set` |
|
||||
| Provider HTTP hang | DoS | `HTTPClient` with a timeout on every goth provider; `throttle:20,1` |
|
||||
| Route shadowing with oauthserver | Tampering | Reserved provider names; unknown names 404 |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- goth v1.82.0 source in module cache: `provider.go`, `session.go`, `user.go`, `gothic/gothic.go`, `providers/{google,facebook,github,azureadv2,microsoftonline}/*.go`
|
||||
- `golang.org/x/oauth2@v0.27.0` `token.go`, `internal/token.go`, `pkce.go`; `go-redis/v9@v9.23.0` `string_commands.go`; `otter/v2@v2.3.0` `cache_impl.go`; `miniredis/v2@v2.40.0`
|
||||
- Measurements this session: `go list -deps`, `go list -m all` diff, demo binary size, `go version -m`, govulncheck, the Postgres race probe, the ServeMux probe
|
||||
- Repo files: `summercms.go/modules/{cooler,bouncer,lagoon,surf,backpack,pact,cabana,wire}`; sm-user-plugin (fonoteka checkout) models/controllers/classes/updates/plugin.go; `sm-oauthserver-plugin` go.mod/go.work/plugin.go/harness_test.go/testmain_test.go/routes.go; `sm-pgstripe-plugin/models/settings.go`; `sm-herald-plugin/boot.go`; sm-demo-app and sm-bm-app config/summer.yaml/go.work/frontend
|
||||
- PHP: wavepath `plugins/golem15/user/{components/SocialAuth.php, controllers/ApiController.php, routes.php, models/User.php, lang/en|pl/lang.php, tests/…}`; fonoteka `OAuthIdentityApiController.php`; Horoskopia `app/stores/auth.ts`, `pages/auth/oauth/*.vue`
|
||||
- Google discovery doc `https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest` (Userinfo `verified_email`)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- RFC 9700 §2.1, §2.1.1 (rfc-editor.org/rfc/rfc9700.html), PKCE/state guidance
|
||||
- github.com/markbates/goth repo page (stars, license, maintainer-search link)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Third-party nOAuth write-ups via web search (Cayosoft, Better Auth docs, a Microsoft Q&A quote): used only as context for the already-locked D-11
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Version from the proxy, imports and size measured, govulncheck run.
|
||||
- Architecture: HIGH for Go integration points (all read), MEDIUM for the bm adoption details (Open Question 4).
|
||||
- Pitfalls: HIGH. Pitfall 1 measured, goth behaviours read from source, Google field from the official discovery doc.
|
||||
- PHP contract: HIGH. Read in full this session.
|
||||
|
||||
**Research date:** 2026-10-19
|
||||
**Valid until:** 2026-11-18 (goth is slow-moving; recheck the user-plugin history state before planning, since 19.4/19.6 sessions are active)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
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
|
||||
created: "2026-10-19"
|
||||
---
|
||||
|
||||
# Phase 19.5 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| 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` |
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **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
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner and executor. Decision → test map from RESEARCH.md § Validation Architecture:
|
||||
|
||||
| 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 |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Live sign-in with Google, Facebook, GitHub and Microsoft | D-04, D-21 | Needs real provider app registrations and secrets | Configure credentials in sm-demo-app (and Entra in sm-bm-app), sign in through the SPA, confirm the callback lands and the identity is listed |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user