Skip to content

auth: lift OIDC into reusable pkg/auth provider package#829

Open
alvarofraguas wants to merge 4 commits into
jmpsec:mainfrom
alvarofraguas:pr/auth-shared-package
Open

auth: lift OIDC into reusable pkg/auth provider package#829
alvarofraguas wants to merge 4 commits into
jmpsec:mainfrom
alvarofraguas:pr/auth-shared-package

Conversation

@alvarofraguas
Copy link
Copy Markdown
Collaborator

Summary

  • Introduces pkg/auth/ with a shared auth.Provider interface (Type(), LoginURL(), HandleCallback()) and JWT-based state/nonce cookie helpers
  • Extracts pkg/auth/oidc/ from the legacy admin's inline OIDC handling into a reusable provider implementation
  • Refactors cmd/admin/handlers/ to consume the shared package — legacy admin OIDC behavior preserved end-to-end
  • pickUsername() reads arbitrary claim names from the raw ID token (fixes Auth0 nickname claim handling)

Depends on

Test plan

  • Legacy admin OIDC login still works against Keycloak
  • Legacy admin OIDC login still works against Auth0
  • go test ./pkg/auth/... ./pkg/users/... passes
  • go vet ./... clean

Scaffolds the protocol-agnostic federated-identity surface for
osctrl-api. No concrete provider yet — that lands on the next branch
(auth/01-oidc-provider).

== types.go ==

Defines:
- Provider interface: Type / LoginURL / HandleCallback. Context-aware
  on every method so a slow IdP cannot wedge a handler.
- ResolvedIdentity: protocol-neutral output of HandleCallback. Subject
  is the stable identifier; PreferredUsername is the display/match
  field; Email is informational (mutable, spoofable — threat T24);
  Groups carries IdP group memberships for the RequiredGroups gate.
- State: per-login data the caller must round-trip from LoginURL to
  HandleCallback. Contains EnvUUID (locks to one env — threat T18),
  Nonce (256-bit cryptorandom), and PKCE Verifier.
- Type constants: TypeOIDC ("oidc") + reserved TypeSAML ("saml").

== state.go ==

State-as-JWT cookie implementation. HS256-signed with the api's
existing JWT secret; audience-scoped to "osctrl-auth-state" so a
state JWT cannot be used as a user-auth JWT and vice versa
(threat T19). Cookie attributes:

- Name: osctrl_auth_state
- Path: /api/v1/auth/   (scoped so the cookie doesn't leak to
                         other endpoints)
- HttpOnly + Secure + SameSite=Lax
- MaxAge: 10 minutes

Verification enforces in order: cookie present, HS256 pinned
(WithValidMethods), signature valid, iss == osctrl-api, aud ==
osctrl-auth-state, exp/nbf valid, EnvUUID + Nonce non-empty.
Single ErrStateInvalid for all failure modes — external observers
cannot distinguish "tampered" from "expired" from "wrong audience"
(threat T31).

NewNonce returns 256-bit cryptorandom base64url-encoded values.
ClearStateCookie deletes the cookie immediately after a successful
parse so a callback URL cannot be replayed (threat T9).

== state_test.go ==

15 tests covering each threat vector in scope for this commit:

  TestNonceUnique               — sanity on cryptorandom (T7)
  TestStateRoundTripHappy       — canonical good path
  TestStateMissing              — no cookie → ErrStateMissing (T6)
  TestStateEmptyValue           — defensive: empty cookie value
  TestStateSignatureTampered    — flipped sig byte → ErrStateInvalid (T31)
  TestStateExpired              — past-exp token rejected (T9)
  TestStateWrongAudience        — user-auth aud rejected (T19)
  TestStateWrongIssuer          — wrong iss rejected (defense in depth)
  TestStateWrongSecret          — attacker-signed rejected (T27)
  TestStateAlgNoneRejected      — alg=none rejected (alg-confusion)
  TestIssueRejectsEmptyEnv      — empty EnvUUID refused (T18 groundwork)
  TestIssueRejectsEmptyNonce    — empty Nonce refused
  TestCookieAttributes          — HttpOnly+Secure+SameSite+Path (T11/T12)
  TestClearStateCookie          — path matches for browser-side delete
  TestStateIsZero               — IsZero helper

All 15 pass. `go vet ./pkg/auth/...` clean. `go build ./...` clean.
Lifts the OIDC protocol code from cmd/admin/oidc.go into a reusable
pkg/auth/oidc package implementing the auth.Provider interface from
auth/00-shared-pkg. Legacy admin is refactored to consume the shared
package; behavior is preserved (backwards-compat audit in spec).

== pkg/auth/oidc/ ==

config.go    — Config struct decoupled from YAML/CLI bindings, with
               Validate() and effective*() helpers. New field
               LegacyPermissiveUsername for the backwards-compat shim.
claims.go    — pickUsername (claim selection), sanitizeUsername
               (strict regex), hasRequiredGroup (group gate with
               malformed-claim defense).
provider.go  — NewOIDCProvider, LoginURL, HandleCallback. Implements
               the auth.Provider interface (compile-time check).
               HandleCallback runs the full 10-step verification
               chain documented inline.

Test coverage (28 oidc subtests; 15 pkg/auth subtests = 43 total):

  TestT1ForgedSignature             — sig mint-by-attacker rejected
  TestT2WrongIssuer                 — iss claim mismatch rejected
  TestT3WrongAudience               — aud claim mismatch rejected
  TestT4Expired                     — exp in the past rejected
  TestNonceMismatch                 — id_token nonce != state nonce
  TestT10PKCEVerifierMissing        — PKCE-on + no verifier rejected
  TestT17RequiredGroupMissing       — gate denies wrong group
  TestT17GroupsClaimMalformed       — string-shaped claim denies
  TestRequiredGroupSatisfied        — happy-path group OK
  TestT18StateEnvMismatch           — cross-env state replay rejected
  TestT23UsernameInjection (6 sub)  — SQL/newline/NUL/etc usernames rejected
  TestIdPErrorParam                 — IdP error_description NOT leaked
  TestMissingCode                   — empty code rejected
  TestLegacyPermissiveUsername(2)   — shim accepts email-format,
                                      still rejects empty/whitespace
  TestHandleCallbackHappy           — full successful exchange
  TestNewOIDCProviderDiscoveryFails — bad discovery rejected at init
  TestLoginURLValidatesState        — empty State.EnvUUID/Nonce rejected
  TestLoginURLPKCERequired          — verifier-missing rejected at LoginURL
  + claim/config unit tests

The in-process fakeIdP test harness boots a real httptest.Server that
serves discovery + JWKS + token endpoints. id_tokens are
RS256-signed with an in-memory RSA key, so the signature/iss/aud/exp
checks all exercise the genuine go-oidc.Verifier path, not a mock.

== cmd/admin/oidc.go refactor ==

The file shrinks from 360 LoC to ~180 LoC by replacing the inline
protocol implementation with calls into pkg/auth/oidc. Preserved:

- Route names (/login, /oidc/callback)
- YAML config field names
- Session-cookie + session-manager flow
- JIT-provisioning policy (zero permissions)
- Audit-log behavior

State-cookie storage switches from gorilla/securecookie to
pkg/auth.IssueStateCookie (HMAC JWT signed with the existing
flagParams.Admin.SessionKey). State cookie name changes from
"osctrl-admin-oidc-state" to "osctrl_auth_state" — operators
mid-OIDC-login during a deploy will need one retry. Documented in
the spec's "accepted minor break" section.

Username validation: pre-refactor cmd/admin did no character-class
check, so deployments where IdPs emit email-format
preferred_username values rely on permissive acceptance. The
Config.LegacyPermissiveUsername flag set by cmd/admin's
fromYAMLConfig preserves that behavior. New callers (cmd/api in a
later branch) leave the flag false and get the strict default.

== cmd/admin/main.go ==

Drops the no-longer-needed `var oidcRT *oidcRuntime` global and
updates initOIDC's call site to match the new (error)-only return.

== Verified ==

- go build ./... clean
- go vet ./... clean
- go test ./... all 16 packages green including the new auth tests
- Backwards-compat audit documented in spec § "Backwards compatibility
  for cmd/admin"
Closes hardening item 5A from the auth spec's pentest plan.

Before: LoginURL passed state.EnvUUID as the OAuth2 `state` parameter,
and HandleCallback compared the returned URL state against
state.EnvUUID. Env identifiers are operator-visible (URLs, logs, UI)
and in many deployments are short, guessable strings ("admin",
"prod", "default"). An attacker who knows or guesses the target's
EnvUUID could craft a callback URL whose state parameter survives
step 2 of the verification chain. The defense was relying entirely
on the cookie's HMAC signature for CSRF protection — a single
mistake in cookie handling would have opened a CSRF hole.

After: LoginURL passes state.Nonce — the 256-bit cryptorandom value
already minted for the id_token nonce — as the OAuth2 state param.
HandleCallback checks `url.state == cookie.Nonce`. The value is now
unguessable, so step 2 alone gates CSRF whether or not the cookie's
HMAC happens to verify. Cookie HMAC remains the second layer.

State.EnvUUID stays in the cookie shape (LoginURL still rejects an
empty one) but becomes informational at the protocol layer. Callers
that want env-scoping above the protocol use it out-of-band:
cmd/admin sets it to "admin"; cmd/api will set it to a global
sentinel.

Test changes:

- TestT18StateEnvMismatch repurposed as TestT6CSRFStateTampering
  with a more accurate threat label: env-replay isn't a threat at
  this layer; CSRF/state-tampering is.
- TestLoginURLValidatesState now asserts state=<nonce> in the
  authorize URL AND asserts that state=<env-uuid> does NOT appear.
- fakeCallback's stateParam argument documented as the nonce.
- TestMissingCode's state param updated so the test reaches step 3
  (missing code) rather than failing at step 2 (state mismatch).

All 28 oidc subtests pass. `go build ./...` and `go vet ./...` clean.
Bug found during Auth0 manual smoke: operators configuring
--oidc-username-claim with a non-standard name (nickname, upn,
login, etc.) silently got the subject claim back instead. The old
switch statement only covered preferred_username/email/sub; any
other configured name fell through to subject — which on Auth0 is
"auth0|hex…" containing the pipe character the strict username
regex rejects.

After: pickUsername now consults a generic raw claim map after the
typed-struct fast path. Any string-valued claim wins; non-string
values (arrays, objects, missing) fall back to subject. The typed
struct fast path stays for the three known claims since it avoids
a map lookup in the hot path.

== Files ==

  pkg/auth/oidc/claims.go
    * pickUsername(c, raw, claim) signature; reads raw[claim] for
      claim names not in the typed struct
  pkg/auth/oidc/provider.go
    * HandleCallback decodes raw map ONCE (was duplicated — earlier
      decode for ResolvedIdentity.Raw moved up so pickUsername can
      reuse it)
  pkg/auth/oidc/claims_test.go
    + TestPickUsernameCustomClaim covers nickname/upn/missing/
      non-string/nil-map paths

== Why this matters ==

Auth0's default sub format (auth0|hex) means operators MUST set a
non-default username claim — and the only practical one in the
profile scope is `nickname`. Without this fix the manual smoke test
against Auth0 silently fails 100% of the time, regardless of how the
operator configures their app.

The fix also unblocks Entra ID (which uses `upn`) and any custom
claim shape operators have set up via Auth0 Actions or Keycloak
mappers.

== Verified ==

- go build ./... clean
- go test ./pkg/auth/... -count=1 — all pass (includes the new
  3-subtest TestPickUsernameCustomClaim covering nickname, upn,
  missing claim, non-string value, nil raw map)
@javuto javuto self-requested a review May 19, 2026 07:32
@javuto javuto added osctrl-admin osctrl-admin related changes 🔐 security Security related issues refactor Refactorization of code labels May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

osctrl-admin osctrl-admin related changes refactor Refactorization of code 🔐 security Security related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants