Add external OIDC sign-in and account linking #63
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/oidc-login"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements task 121 against ADR-0032's flow:
/api/auth/oidc/{challenge,callback,redeem,link/begin}. The API completes the authorisation code flow itself with PKCE against the provider, and separately with the redemption-verifier construction between the callback and the WebUI's redeem call; no provider identity ever reaches a PlaceMark token. Provider identity is resolved only via a server-side, in-process flow record (SingleUseStore<T>) — nothing persisted, per ADR-0032's single-instance constraint.Linking follows ADR-0017/ADR-0032 exactly: the
external_identitiesrow is written only at redeem, from the caller's own bearer token, never from the redirect or the request body. An OIDC identity whoseemailmatches an existing local account is refused (409), not linked or merged — covered directly by test.First-time sign-in provisions the user, personal group, Owner membership and
external_identitiesrow in one transaction (RegistrationRepository.ProvisionFromOidcAsync, reusingRegisterAsync's shape). Display name resolution (name→preferred_username→ local part ofemail→ placeholder, never the full address) is ADR-0057.No schema script:
external_identitiesalready existed in0001-initial-schema.sql; the flow/handle/handback records are in-process only.Decision needing no further action here, flagged for Rob: an OIDC-only account still has no self-service route through
DELETE /api/users/me(the gap PR #60 documented) — fixing it properly needs step-up re-authentication, which ADR-0017 leaves open generally rather than just for this route. Left open deliberately; commented on task 121 recommending a dedicated follow-up ticket, since I have no task-creation tool.Provider is Authentik at the deployment level only — nothing about it appears in code; integration tests run against a real, second, stubbed OIDC provider (
StubOidcProvider, a second in-process Kestrel host) rather than a substitute.AddPlaceMarkOidcis mandatory at start-up, refusing on a missing/malformed authority, redirect URIs, client id or secret, mirroring the JWT signing key. Every existingWebApplicationFactory-based test keeps working unchanged —TestJwtConfigurationnow also carries well-formed-but-unreachableOidc:*defaults.Confirmed (not assumed) that ADR-0037's rate limiter covers the new routes:
OidcRateLimitingTests.Verdict: changes needed
Tested this by attack rather than by reading, in a scratch worktree with
mainmerged in (two additive conflicts indocs/adr/README.mdandOpenApiDocumentTests.cs, resolved trivially). Full suite green under the pinned SDK (10.0.100): 520 + 267 + others, including a freshdotnet test --filter Oidcrun against a real Postgres and the realStubOidcProviderhost.Account-takeover checklist — all held:
emailon first sign-in: refused 409 (ProvisionAsync'sFindByEmailAsynccheck), never links, never merges.email_verifiedplays no part anywhere, matching ADR-0017's "no address needs to be verified for a link to occur" — correct, since a true/false claim on that field must not change the outcome.user_idonly from the bearer token (CompleteLinkAsync), compared against — never sourced from — the begin-link handle's recorded value. Tried redeeming a link handle minted for one account with a different account's token: 401, no row written (Redeem_LinkHandleWasMintedForADifferentAccount_RefusesTheLink).Redeem_HandbackCodePresentedWithTheWrongVerifier_...) — a leaked code genuinely redeems nothing.SingleUseStore<T>is aConcurrentDictionary.TryRemove, so replay of a consumed handle and reuse of an expired one both fail atomically; unredeemed records simply age out on the nextCreate's prune. No background reaping, honestly stated as such.Oidc:Authority,ClientSecret,RedirectUriall throwInvalidOperationExceptionbeforeBuild()returns, proven against the real host (OidcStartupTests).ProvisionFromOidcAsyncis one transaction, four inserts, rollback via undisposed-uncommittedNpgsqlTransaction;RegistrationRepositoryOidcTestsforces both the email-collision and identity-already-linked failure paths and asserts zero rows left behind.OidcDisplayName.Resolvenever returns a full address — tested with email-only input and with an addr-only local part (@example.com) falling through to the placeholder rather than emitting an empty string.OidcRateLimitingTestsproves the limit independently againstGET .../challenge, not just cited by construction.SingleUseStore<T>'s in-process/single-instance limitation is documented honestly, including the concrete failure mode (a flow that began on one instance and returns to another fails intermittently, silently) — no overstatement.One thing to fix before merge:
StubOidcProvider.csline 19 names the provider — "nothing named Authentik or any other product appears anywhere in this class or in the code under test" — which puts the literal string in a comment, in code, on this branch. It's a disclaimer rather than a real dependency, but the ticket's acceptance criterion is "no provider is named in code" with no carve-out for comments, and it's the one thing a case-insensitive grep across the branch actually turns up. Reword to drop the name (e.g. "nothing names the real product this stubs").Nothing else found worth acting on.
Fixed: reworded the
StubOidcProvidercomment to describe it generically rather than naming a product. Grepped the branch case-insensitively for the name — nothing else found.Merged
mainin (PR #61, move-place-endpoint) — resolved the ADR index andOpenApiDocumentTestsconflicts. Full CI sequence (build,dotnet format --verify-no-changes, full test suite) reran clean after the merge.Verdict: mergeable
Re-reviewed the delta only, at
0ddee5f, in a fresh scratch worktree.7b11da5touches onlyStubOidcProvider.cs's doc comment (6 insertions / 5 deletions, nothing else). Confirmed independently, not just diffed:git grep -ilE authentikat0ddee5facross the whole tree, and a filesystem-level case-insensitive grep of both file contents and filenames outside.git/bin/obj— both zero hits.mainmerge:docs/adr/README.mdindex runs 0001–0055, 0057, strictly ascending, both the 0055 and 0057 rows present with their "Open questions" entries; no 0056 exists on either parent, so its absence isn't a merge defect.OpenApiDocumentTests.cs's route list is the true 23-entry union — 18 shared + the 4 OIDC-only routes from this branch +/api/places/{id}/groupfrom main — with/api/users/meappearing exactly once, not duplicated or dropped. Full diff from7b11da5to0ddee5fis nine files, all pure additions (the move-place-endpoint feature) plus the 7-line and 1-line conflict resolutions in those two files — nothing interleaved wrongly, nothing silently lost.git diff --statof every OIDC-touching path (Authentication/Oidc/*,Infrastructure/Authentication/*and their test trees) between9407d35and0ddee5fshows only the one comment file.dotnet testrun: 13 + 12 + 105 + 17 + 520 + 267 passed, 0 failed. The flagged flaky mutual-demotion test didn't fire this run; noting it wasn't seen, not that it can't be.Nothing else found worth acting on.