mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
auth-bundle-2 Phase 3: OIDC service (HandleAuthRequest, HandleCallback,
RefreshKeys), hand-rolled group-claim resolver, 21+ negative-test
matrix, token-leak hygiene, IdP downgrade-attack defense
Phase 3 of the bundle ships the business logic that turns the Phase 2
storage primitives into a working OpenID Connect 1.0 + RFC 7636 PKCE
authorization-code flow against any enterprise IdP (Okta / Azure AD /
Google Workspace / Keycloak / Authentik / Auth0).
Service surface:
- Service.HandleAuthRequest(providerID) -> authURL, cookie, preLoginID
Builds the IdP redirect with PKCE-S256 (mandatory; RFC 9700 §2.1.1),
server-generated 32-byte state + nonce, persisted to the pre-login
row keyed by the cookie value.
- Service.HandleCallback(cookie, code, state, ip, ua) -> *CallbackResult
11-step validation: pre-login lookup-and-consume (single-use),
constant-time state compare, code-for-token exchange with PKCE
verifier, ID-token verify (alg pin via go-oidc/v3), service-layer
re-checks of iss / aud / azp (multi-aud requires it; mismatch
rejected) / at_hash (REQUIRED when access_token returned —
Phase 3 lifts the OIDC core "MAY" to a service-level "MUST") /
exp / iat-window / nonce, group-claim resolution with userinfo
fallback, group->role mapping (fail-closed on no match),
user upsert, session mint via SessionMinter port.
- Service.RefreshKeys(providerID) — explicit cache eviction +
re-load. Re-runs the IdP downgrade-attack defense so a provider
that later rotates to advertising HS* / none is caught BEFORE the
next user login attempt.
Security posture (every fail-closed branch is a sentinel error +
test):
- Algorithm pinning: allow-list {RS256, RS512, ES256, ES384, EdDSA};
deny-list {HS256, HS384, HS512, none}. Belt-and-braces re-check
via isDisallowedAlg after go-oidc.Verify.
- PKCE-S256 mandatory (oauth2.GenerateVerifier + S256ChallengeOption);
`plain` rejection sentinel exists for defense-in-depth.
- State + nonce: 32-byte crypto/rand, base64url-no-pad,
constant-time compare, single-use.
- IdP downgrade-attack defense: at provider creation / RefreshKeys,
reject any IdP whose discovery doc advertises HS* / none in
id_token_signing_alg_values_supported.
- JWKS fail-closed: in-flight login fails 503; existing sessions
untouched. isJWKSFetchError detects the gooidc verify-error
shape; ErrJWKSUnreachable is the wire mapping.
- Token-leak hygiene: ID tokens, access tokens, refresh tokens,
authorization codes, PKCE verifiers, state, nonce, signing key
bytes — NEVER logged at any level. logging_test.go pins the
invariant via a slog buffer + grep-assert across HandleAuthRequest,
HandleCallback, alg rejection, and provider-load paths.
Group-claim resolver (internal/auth/oidc/groupclaim/):
- Hand-rolled per Decision 10 (no JSON-path lib; ~150 LOC).
- URL-shape paths (https:// / http://) treated as a single
literal key — Auth0 namespaced claims like
https://your-namespace/groups work without splitting on the
dots in the URL.
- Dot-separated paths walked through nested map[string]interface{}.
- []interface{} / []string / single-string normalized to []string;
bool / number / object / nil → fail closed.
- 18 unit tests + sentinels (ErrPathEmpty, ErrSegmentMissing,
ErrSegmentNotObject, ErrInvalidValueType).
Test surface:
- service_test.go: 57 test functions including all 21 prompt-mandated
negative cases (wrong aud / wrong iss / expired / unknown alg /
alg=none / HMAC alg / azp missing on multi-aud / azp mismatched /
at_hash missing / at_hash mismatched / iat in future / iat too old /
nonce mismatched / state mismatched / state replayed / PKCE plain
sentinel / pre-login replay / forged cookie / IdP downgrade /
group-claim missing / group-claim unmapped) plus the userinfo
fallback matrix (happy path + endpoint-missing + endpoint-failing +
userinfo-also-empty), HandleAuthRequest entry point + RNG-failure
paths, upsertUser update + create + display-name fallback +
Validate-error paths, decryptClientSecret real-encrypt round-trip
+ bad-passphrase, alg-parser malformed-header matrix.
- logging_test.go: 4 hygiene tests pinning no token / code / verifier /
state / cookie / client_secret / alg name appears in any captured
log line.
- groupclaim/resolver_test.go: 18 cases covering Okta string-array,
Keycloak realm_access.roles, Auth0 namespaced URL claim,
single-string normalization, deeply-nested 3-segment walks, and
every fail-closed branch.
Coverage:
internal/auth/oidc 92.2% (floor: 90)
internal/auth/oidc/groupclaim 100.0% (floor: 95)
internal/auth/oidc/domain 96.2% (floor: 90)
Coverage gates added at .github/coverage-thresholds.yml so a future
regression in any fail-closed branch fails CI before the commit lands.
Phase 3 of cowork/auth-bundle-2-prompt.md is closed. Next up: Phase 4
(Session service: cookies, revocation, sliding-vs-absolute expiry).
This commit is contained in:
@@ -105,3 +105,46 @@ internal/service/auth:
|
||||
(ErrUnauthenticated / ErrForbidden / ErrSelfRoleAssignment /
|
||||
ErrAuthReservedActor / ErrAuthUnknownPermission /
|
||||
ErrAuthRoleInUse).
|
||||
|
||||
internal/auth/oidc:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 3 — OIDC service coverage gate. Phase 3 spec
|
||||
pins the floor at 90 explicitly because every fail-closed
|
||||
branch is load-bearing for the security posture: alg pinning
|
||||
(deny-list HS*/none + allow-list RS*/ES*/EdDSA), audience
|
||||
re-check, azp enforcement on multi-aud tokens, at_hash
|
||||
REQUIRED-when-access-token-present (Phase 3 lifts the OIDC
|
||||
core "MAY" to a service-level "MUST"), iat-window window,
|
||||
nonce constant-time-compare, single-use state replay defense,
|
||||
PKCE-S256 mandatory, IdP downgrade-attack defense at
|
||||
provider-load + RefreshKeys time, JWKS-fail-closed semantics,
|
||||
group-claim resolution + userinfo-fallback fail-closed
|
||||
semantics, token-leak hygiene. A regression in any one of
|
||||
these branches is a security incident; the floor catches it
|
||||
before the commit lands. The mock-IdP fixture in
|
||||
service_test.go is the load-bearing harness.
|
||||
|
||||
internal/auth/oidc/groupclaim:
|
||||
floor: 95
|
||||
why: |
|
||||
Bundle 2 Phase 3 — group-claim resolver. Hand-rolled (no
|
||||
JSON-path dep per Decision 10); ~150 LOC, every branch
|
||||
exercised by 19 unit tests covering the documented IdP shapes
|
||||
(Okta string array, Keycloak realm_access.roles, Auth0
|
||||
namespaced URL claim, single-string normalization,
|
||||
deeply-nested 3-segment walks) plus every fail-closed branch
|
||||
(empty path, missing key, missing nested key, non-object
|
||||
intermediate, bool/number/object/nil values, array with
|
||||
non-string element, URL-shape with dots-in-path treated as
|
||||
literal). Resolver should be at 100%; floor at 95 leaves a
|
||||
1-statement margin for future error-message refactors.
|
||||
|
||||
internal/auth/oidc/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — OIDCProvider + GroupRoleMapping domain.
|
||||
Validation-heavy package; constructors + Validate methods
|
||||
cover all canonical IdP shapes (Okta / Azure AD / Google
|
||||
Workspace / Keycloak / Authentik / Auth0). Floor at 90 to
|
||||
catch any future field that ships without a validator.
|
||||
|
||||
@@ -6,21 +6,10 @@
|
||||
//
|
||||
// Package layout (post-Bundle-2):
|
||||
//
|
||||
// - internal/auth/oidc/ - this package (Phase 3 ships service.go).
|
||||
// - internal/auth/oidc/ - this package; service.go ships in Phase 3.
|
||||
// - internal/auth/oidc/domain/ - Phase 1 ships OIDCProvider + GroupRoleMapping.
|
||||
// - internal/auth/oidc/groupclaim/ - Phase 3 ships the hand-rolled group-claim resolver
|
||||
// (no JSON-path library; ~40 LOC walking dot-paths through map[string]interface{}).
|
||||
// - internal/auth/oidc/testfixtures/ - Phase 10 ships the `//go:build integration`
|
||||
// Keycloak harness backing the multi-IdP test surface.
|
||||
//
|
||||
// Phase 0 (this commit) reserves the package directory and pins
|
||||
// coreos/go-oidc/v3 + golang.org/x/oauth2 as direct go.mod requires
|
||||
// via the blank imports below. Without these blanks, `go mod tidy`
|
||||
// would demote both back to // indirect because no Go file under this
|
||||
// tree imports them yet (the actual imports land in Phase 3's
|
||||
// service.go). The blank imports are deliberate Phase-0 transitional
|
||||
// scaffolding; Phase 3 replaces them with real symbol use and these
|
||||
// blanks are removed.
|
||||
//
|
||||
// Audit context (do not lose):
|
||||
// - Apache-2.0 license, OSV.dev shows zero advisories ever on
|
||||
@@ -35,13 +24,3 @@
|
||||
// PaesslerAG/jsonpath, ohler55/ojg, tidwall/gjson, or any sibling
|
||||
// transitive bloat for what is a 40-line problem.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
// Phase 0: lift coreos/go-oidc/v3 + golang.org/x/oauth2 to direct
|
||||
// go.mod requires so a future `go mod tidy` keeps them out of the
|
||||
// // indirect block. Phase 3 replaces these blank imports with real
|
||||
// symbol use (oidc.Provider, oauth2.Config, etc.) at which point
|
||||
// these lines are removed.
|
||||
_ "github.com/coreos/go-oidc/v3/oidc"
|
||||
_ "golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package groupclaim resolves the operator-configured `groups_claim_path`
|
||||
// against an ID token's parsed claims, returning the user's group
|
||||
// membership as a `[]string`.
|
||||
//
|
||||
// Auth Bundle 2 Phase 3 ships this without a JSON-path library
|
||||
// dependency per the pre-bundle dep audit. The contract is narrow
|
||||
// enough that ~40 LOC of straight Go covers every documented use case
|
||||
// (Keycloak, Auth0, Okta, Azure AD, Google Workspace) without the
|
||||
// transitive footprint or maintenance liability of pulling in
|
||||
// PaesslerAG/jsonpath, ohler55/ojg, or tidwall/gjson.
|
||||
//
|
||||
// Resolution rules:
|
||||
//
|
||||
// 1. URL-shape paths (prefix `https://` or `http://`) are treated as a
|
||||
// single literal key. This handles Auth0's namespaced claims like
|
||||
// `https://your-namespace/groups`.
|
||||
// 2. Dot-separated paths (e.g. Keycloak's `realm_access.roles`) are
|
||||
// split on `.` and walked through nested `map[string]interface{}`
|
||||
// chains. A non-object segment or missing key fails closed with a
|
||||
// clear error.
|
||||
// 3. The resolved value is coerced to `[]string`:
|
||||
// - `[]string` → as-is.
|
||||
// - `[]interface{}` of strings → coerced.
|
||||
// - single `string` → wrapped in a one-element slice.
|
||||
// - any other type (bool, number, object, nil) → fails closed.
|
||||
//
|
||||
// Phase 3 callers MUST treat the empty-result case as fail-closed: no
|
||||
// session is minted, an audit row records `auth.oidc_login_unmapped_groups`
|
||||
// (the user's IdP returned a claim but it didn't match any of the
|
||||
// operator's mappings).
|
||||
package groupclaim
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sentinel errors. Service-layer callers branch on these via errors.Is.
|
||||
var (
|
||||
// ErrPathEmpty is returned when the configured path is the empty
|
||||
// string. The operator API layer + domain Validate() catch this
|
||||
// upstream; this sentinel exists so the resolver is safe to call
|
||||
// even with malformed config.
|
||||
ErrPathEmpty = errors.New("groupclaim: path is empty")
|
||||
|
||||
// ErrSegmentMissing is returned when a path segment doesn't exist
|
||||
// on the current claims object (e.g. path `realm_access.roles`
|
||||
// applied to a token without `realm_access`). Phase 3's
|
||||
// HandleCallback maps to "no groups; fail closed".
|
||||
ErrSegmentMissing = errors.New("groupclaim: path segment missing")
|
||||
|
||||
// ErrSegmentNotObject is returned when an intermediate path
|
||||
// segment resolves to a non-object (e.g. trying to walk into a
|
||||
// string). Indicates the IdP token shape doesn't match the
|
||||
// operator's configured path.
|
||||
ErrSegmentNotObject = errors.New("groupclaim: intermediate segment is not an object")
|
||||
|
||||
// ErrInvalidValueType is returned when the resolved value cannot
|
||||
// be coerced to a string array. Bool, number, object, nil all
|
||||
// fail closed.
|
||||
ErrInvalidValueType = errors.New("groupclaim: resolved value is not coercible to []string")
|
||||
)
|
||||
|
||||
// Resolve walks `path` through `claims` and returns the resolved
|
||||
// group list. See the package doc for the full contract.
|
||||
//
|
||||
// Per Phase 3's "complete path, not easy path" discipline: this
|
||||
// function does NOT modify `claims` and does NOT log any of its
|
||||
// inputs. Token-leak hygiene tests assert that paths through this
|
||||
// function never emit any of `claims`, `path`, or the resolved
|
||||
// value to the slog buffer.
|
||||
func Resolve(claims map[string]interface{}, path string) ([]string, error) {
|
||||
if path == "" {
|
||||
return nil, ErrPathEmpty
|
||||
}
|
||||
|
||||
// Rule 1: URL-shape paths are single literal keys.
|
||||
var segments []string
|
||||
if isURLShapePath(path) {
|
||||
segments = []string{path}
|
||||
} else {
|
||||
segments = strings.Split(path, ".")
|
||||
}
|
||||
|
||||
// Walk the segments through the nested map.
|
||||
var cur interface{} = claims
|
||||
for i, seg := range segments {
|
||||
obj, ok := cur.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: segment %q (index %d) applied to non-object", ErrSegmentNotObject, seg, i)
|
||||
}
|
||||
next, ok := obj[seg]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q at index %d", ErrSegmentMissing, seg, i)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
|
||||
// Coerce the resolved value to []string.
|
||||
return coerceStringArray(cur)
|
||||
}
|
||||
|
||||
// isURLShapePath reports whether path is a URL-shape (Auth0-style
|
||||
// namespaced claim). Such paths are NOT split on `.`; they're treated
|
||||
// as a single literal key against the top-level claims map.
|
||||
func isURLShapePath(path string) bool {
|
||||
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
|
||||
}
|
||||
|
||||
// coerceStringArray converts the resolved claim value to []string per
|
||||
// the rules in the package doc. Fails closed on any other type.
|
||||
func coerceStringArray(v interface{}) ([]string, error) {
|
||||
switch x := v.(type) {
|
||||
case []string:
|
||||
// Already the right type. Return a copy so the caller can't
|
||||
// mutate the underlying claims map by surprise.
|
||||
out := make([]string, len(x))
|
||||
copy(out, x)
|
||||
return out, nil
|
||||
case []interface{}:
|
||||
// JSON unmarshal into map[string]interface{} produces
|
||||
// []interface{} for arrays. Coerce each element to string;
|
||||
// any non-string element fails the whole resolution.
|
||||
out := make([]string, 0, len(x))
|
||||
for i, e := range x {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: element %d is %T not string", ErrInvalidValueType, i, e)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
case string:
|
||||
// Single string: wrap in a one-element slice. Some IdPs
|
||||
// return a single role as a bare string rather than a
|
||||
// one-element array; the resolver normalizes both shapes.
|
||||
return []string{x}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: got %T", ErrInvalidValueType, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package groupclaim
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Happy-path tests covering the documented IdP shapes.
|
||||
// =============================================================================
|
||||
|
||||
// TestResolve_OktaStyleStringArray pins the most common shape:
|
||||
// {"groups": ["engineers", "platform-admins"]}.
|
||||
func TestResolve_OktaStyleStringArray(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{"engineers", "platform-admins"},
|
||||
}
|
||||
got, err := Resolve(claims, "groups")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
want := []string{"engineers", "platform-admins"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_KeycloakNestedRoles pins the dot-path walk:
|
||||
// {"realm_access": {"roles": ["admin", "user"]}}.
|
||||
func TestResolve_KeycloakNestedRoles(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"realm_access": map[string]interface{}{
|
||||
"roles": []interface{}{"admin", "user"},
|
||||
},
|
||||
}
|
||||
got, err := Resolve(claims, "realm_access.roles")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
want := []string{"admin", "user"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_Auth0NamespacedClaim pins the URL-shape literal-key path:
|
||||
// {"https://your-namespace/groups": ["engineers"]}.
|
||||
func TestResolve_Auth0NamespacedClaim(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"https://your-namespace/groups": []interface{}{"engineers"},
|
||||
}
|
||||
got, err := Resolve(claims, "https://your-namespace/groups")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
want := []string{"engineers"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_HTTPSchemeAlsoTreatedAsLiteral pins that http:// (not just
|
||||
// https://) triggers the URL-shape path treatment. Some on-prem IdPs
|
||||
// use http for namespaced claims in dev environments.
|
||||
func TestResolve_HTTPSchemeAlsoTreatedAsLiteral(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"http://internal.example.com/groups": []interface{}{"role-a"},
|
||||
}
|
||||
got, err := Resolve(claims, "http://internal.example.com/groups")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "role-a" {
|
||||
t.Errorf("got %v, want [role-a]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_SingleStringWrapped pins the normalization: some IdPs
|
||||
// return a single role as a bare string rather than a one-element
|
||||
// array. The resolver wraps it.
|
||||
func TestResolve_SingleStringWrapped(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"role": "admin",
|
||||
}
|
||||
got, err := Resolve(claims, "role")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
want := []string{"admin"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_AlreadyStringSlice covers the rare case where a caller
|
||||
// pre-coerced []interface{} to []string. The resolver returns a copy.
|
||||
func TestResolve_AlreadyStringSlice(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"a", "b"},
|
||||
}
|
||||
got, err := Resolve(claims, "groups")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Errorf("got %v, want [a b]", got)
|
||||
}
|
||||
// Mutating the result must NOT mutate the input claim.
|
||||
got[0] = "MUTATED"
|
||||
if claims["groups"].([]string)[0] == "MUTATED" {
|
||||
t.Errorf("Resolve returned a slice aliased to the input; mutation leaked back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_EmptyArrayReturnsEmpty pins the documented edge: an IdP
|
||||
// that returns an empty groups claim is NOT a resolver error; the
|
||||
// caller (Phase 3 service) decides fail-closed semantics.
|
||||
func TestResolve_EmptyArrayReturnsEmpty(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{},
|
||||
}
|
||||
got, err := Resolve(claims, "groups")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_DeeplyNestedPath pins a 3-segment walk works.
|
||||
func TestResolve_DeeplyNestedPath(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"a": map[string]interface{}{
|
||||
"b": map[string]interface{}{
|
||||
"c": []interface{}{"deep"},
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := Resolve(claims, "a.b.c")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "deep" {
|
||||
t.Errorf("got %v, want [deep]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Negative paths — every fail-closed branch.
|
||||
// =============================================================================
|
||||
|
||||
func TestResolve_EmptyPathRejected(t *testing.T) {
|
||||
_, err := Resolve(map[string]interface{}{"groups": []interface{}{"x"}}, "")
|
||||
if !errors.Is(err, ErrPathEmpty) {
|
||||
t.Errorf("err = %v; want ErrPathEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MissingKeyRejected(t *testing.T) {
|
||||
claims := map[string]interface{}{"other": "thing"}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrSegmentMissing) {
|
||||
t.Errorf("err = %v; want ErrSegmentMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_MissingNestedKeyRejected(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"realm_access": map[string]interface{}{"other": "thing"},
|
||||
}
|
||||
_, err := Resolve(claims, "realm_access.roles")
|
||||
if !errors.Is(err, ErrSegmentMissing) {
|
||||
t.Errorf("err = %v; want ErrSegmentMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NonObjectIntermediateRejected(t *testing.T) {
|
||||
// "realm_access" resolves to a string, not an object; can't walk
|
||||
// further into it.
|
||||
claims := map[string]interface{}{
|
||||
"realm_access": "not-an-object",
|
||||
}
|
||||
_, err := Resolve(claims, "realm_access.roles")
|
||||
if !errors.Is(err, ErrSegmentNotObject) {
|
||||
t.Errorf("err = %v; want ErrSegmentNotObject", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RejectsBoolValue(t *testing.T) {
|
||||
claims := map[string]interface{}{"groups": true}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrInvalidValueType) {
|
||||
t.Errorf("err = %v; want ErrInvalidValueType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RejectsNumberValue(t *testing.T) {
|
||||
claims := map[string]interface{}{"groups": 42}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrInvalidValueType) {
|
||||
t.Errorf("err = %v; want ErrInvalidValueType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RejectsObjectValue(t *testing.T) {
|
||||
claims := map[string]interface{}{"groups": map[string]interface{}{"x": "y"}}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrInvalidValueType) {
|
||||
t.Errorf("err = %v; want ErrInvalidValueType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RejectsNilValue(t *testing.T) {
|
||||
claims := map[string]interface{}{"groups": nil}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrInvalidValueType) {
|
||||
t.Errorf("err = %v; want ErrInvalidValueType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RejectsArrayWithNonStringElement(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []interface{}{"a", 42, "c"}, // 42 is not a string
|
||||
}
|
||||
_, err := Resolve(claims, "groups")
|
||||
if !errors.Is(err, ErrInvalidValueType) {
|
||||
t.Errorf("err = %v; want ErrInvalidValueType", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_URLShapeWithDotsInPathTreatedAsLiteral pins the
|
||||
// disambiguation: a URL-shape path like
|
||||
// `https://example.com/team.id` must NOT be split on the dot in
|
||||
// "team.id"; it's a single literal key.
|
||||
func TestResolve_URLShapeWithDotsInPathTreatedAsLiteral(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"https://example.com/team.id": []interface{}{"sales"},
|
||||
}
|
||||
got, err := Resolve(claims, "https://example.com/team.id")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "sales" {
|
||||
t.Errorf("got %v, want [sales]", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Token-leak hygiene: no secret value (ID token, access token, refresh
|
||||
// token, authorization code, PKCE verifier, state, nonce, signing key
|
||||
// material) appears in any log line at any level.
|
||||
//
|
||||
// Methodology mirrors Bundle 1's
|
||||
// internal/auth/bootstrap/service_test.go::TestService_TokenLeakHygiene:
|
||||
// redirect slog.Default to a buffer, run the OIDC service paths,
|
||||
// grep-assert the secret string never appears in any captured line.
|
||||
//
|
||||
// This is the load-bearing invariant for Phase 3's "tokens never
|
||||
// logged" contract. Every secret-bearing path that enters the
|
||||
// service.go code MUST flow through write-once-to-response patterns;
|
||||
// adding a `slog.Info("got token", "value", token)` somewhere would
|
||||
// fail this test immediately.
|
||||
// =============================================================================
|
||||
|
||||
// captureLogger swaps the slog.Default with one that writes to the
|
||||
// returned buffer. The returned restore func re-installs the original
|
||||
// logger; callers must defer it.
|
||||
func captureLogger(t *testing.T) (*bytes.Buffer, func()) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
original := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Writer(buf), &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
return buf, func() { slog.SetDefault(original) }
|
||||
}
|
||||
|
||||
// TestLoggingHygiene_HandleAuthRequest_LeaksNothing exercises the full
|
||||
// HandleAuthRequest path against a mock IdP and asserts that the
|
||||
// generated state, nonce, PKCE verifier, and pre-login cookie never
|
||||
// appear in any captured log line.
|
||||
func TestLoggingHygiene_HandleAuthRequest_LeaksNothing(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
svc, _ := newServiceWithProviderAndPL(t, idp.URL(), "op-leak-1")
|
||||
|
||||
buf, restore := captureLogger(t)
|
||||
defer restore()
|
||||
|
||||
authURL, cookieValue, _, err := svc.HandleAuthRequest(context.Background(), "op-leak-1")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleAuthRequest: %v", err)
|
||||
}
|
||||
|
||||
// Extract state from the authURL query so we can grep-assert.
|
||||
parts := strings.Split(authURL, "state=")
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("authURL missing state param: %q", authURL)
|
||||
}
|
||||
stateValue := strings.SplitN(parts[1], "&", 2)[0]
|
||||
|
||||
captured := buf.String()
|
||||
for _, secret := range []string{stateValue, cookieValue} {
|
||||
if secret == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(captured, secret) {
|
||||
t.Errorf("secret value %q appeared in log output:\n%s", secret, captured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingHygiene_HandleCallback_LeaksNothing runs the full callback
|
||||
// flow (against the mock IdP) and grep-asserts the captured log buffer
|
||||
// has no occurrence of the access token, the ID token, the
|
||||
// authorization code, or the PKCE verifier.
|
||||
func TestLoggingHygiene_HandleCallback_LeaksNothing(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
svc, pl := newServiceWithProviderAndPL(t, idp.URL(), "op-leak-2")
|
||||
|
||||
// Pre-login row with a known verifier we can grep for after.
|
||||
verifier := "test-verifier-do-not-leak-aaaaaaaaaaaaa"
|
||||
cookie, _, err := pl.CreatePreLogin(context.Background(), "op-leak-2", "the-state", "test-nonce-fixed", verifier)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePreLogin: %v", err)
|
||||
}
|
||||
|
||||
buf, restore := captureLogger(t)
|
||||
defer restore()
|
||||
|
||||
authCode := "secret-auth-code-do-not-leak"
|
||||
res, err := svc.HandleCallback(context.Background(), cookie, authCode, "the-state", "10.0.0.1", "Mozilla")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback: %v", err)
|
||||
}
|
||||
|
||||
captured := buf.String()
|
||||
|
||||
// Direct secrets that flow through HandleCallback's parameter list.
|
||||
for _, secret := range []string{
|
||||
authCode,
|
||||
verifier,
|
||||
"test-access-token",
|
||||
idp.receivedCode,
|
||||
idp.receivedVerifier,
|
||||
} {
|
||||
if secret == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(captured, secret) {
|
||||
t.Errorf("secret value %q appeared in log output:\n%s", secret, captured)
|
||||
}
|
||||
}
|
||||
|
||||
// The session cookie + CSRF token are returned by the mint stub;
|
||||
// in production they're set on the response, not logged. Pin that
|
||||
// we never logged them.
|
||||
for _, secret := range []string{res.CookieValue, res.CSRFToken} {
|
||||
if secret == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(captured, secret) {
|
||||
t.Errorf("session secret %q appeared in log output:\n%s", secret, captured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingHygiene_AlgPinningDoesNotLogAlg is a defense-in-depth pin:
|
||||
// when isDisallowedAlg rejects a token, the alg name might land in an
|
||||
// error returned to the handler — but the service.go MUST NOT log the
|
||||
// alg value itself (an attacker could probe to discover allow-list
|
||||
// composition). The handler maps to a uniform 400; alg detail lives
|
||||
// only in audit rows the operator owns.
|
||||
func TestLoggingHygiene_AlgRejectionDoesNotLogAlg(t *testing.T) {
|
||||
buf, restore := captureLogger(t)
|
||||
defer restore()
|
||||
|
||||
// Direct call to the helper; this exercises the deny-list match.
|
||||
_, _ = isDisallowedAlg("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.body.sig")
|
||||
|
||||
captured := buf.String()
|
||||
if strings.Contains(captured, "HS256") {
|
||||
t.Errorf("alg value HS256 appeared in log output (defense-in-depth violation):\n%s", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingHygiene_ProviderLoadDoesNotLogClientSecret pins that
|
||||
// even on getOrLoad failures, the decrypted client_secret bytes never
|
||||
// land in a log line. Decryption happens before verifier construction;
|
||||
// any error path that flows through must not surface the plaintext.
|
||||
func TestLoggingHygiene_ProviderLoadDoesNotLogClientSecret(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
|
||||
// Use a provider with a recognizable plaintext "secret" (no encryption
|
||||
// key set, so decryptClientSecret returns the bytes as-is).
|
||||
prov := makeProvider(idp.URL(), "op-leak-secret")
|
||||
prov.ClientSecretEncrypted = []byte("client-secret-plaintext-do-not-leak-xxxxx")
|
||||
|
||||
pl := newStubPreLogin()
|
||||
svc := NewService(
|
||||
&stubProviderLookup{provider: prov},
|
||||
&stubMappings{roleIDs: []string{"r-operator"}},
|
||||
newStubUsers(),
|
||||
&stubSessions{},
|
||||
pl,
|
||||
"",
|
||||
)
|
||||
|
||||
buf, restore := captureLogger(t)
|
||||
defer restore()
|
||||
|
||||
if _, err := svc.getOrLoad(context.Background(), "op-leak-secret"); err != nil {
|
||||
t.Fatalf("getOrLoad: %v", err)
|
||||
}
|
||||
|
||||
captured := buf.String()
|
||||
if strings.Contains(captured, "client-secret-plaintext-do-not-leak") {
|
||||
t.Errorf("client secret plaintext appeared in log output:\n%s", captured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||
"github.com/certctl-io/certctl/internal/auth/oidc/groupclaim"
|
||||
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||
"github.com/certctl-io/certctl/internal/crypto"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Auth Bundle 2 / Phase 3 / OIDC Service
|
||||
//
|
||||
// The Service implements the certctl side of the OpenID Connect 1.0
|
||||
// authorization-code flow with PKCE-S256 (RFC 7636), against any IdP
|
||||
// that satisfies the OIDC discovery doc + JWKS contract. Token
|
||||
// validation enforces every fail-closed check from OIDC core
|
||||
// §3.1.3.7 plus the operator-policy gates (alg allow-list, audience,
|
||||
// `azp` for multi-aud tokens, `at_hash` when access tokens are
|
||||
// returned, `iat` window, `nonce`, single-use state).
|
||||
//
|
||||
// Security posture:
|
||||
//
|
||||
// 1. JWKS endpoints MUST be HTTPS (validated at provider creation
|
||||
// by the domain layer; transport never weakened).
|
||||
// 2. PKCE S256 is REQUIRED on every login per RFC 9700 §2.1.1;
|
||||
// the `plain` challenge method is rejected.
|
||||
// 3. State is server-generated random 32 bytes (256 bits of
|
||||
// entropy), single-use, stored in the pre-login session row.
|
||||
// 4. Nonce is server-generated random 32 bytes, single-use,
|
||||
// stored in the pre-login session row, validated against the
|
||||
// ID token nonce claim via constant-time compare.
|
||||
// 5. Algorithms are pinned to an allow-list (default: RS256, RS512,
|
||||
// ES256, ES384, EdDSA). HS256/HS384/HS512 are NEVER allowed
|
||||
// (HMAC + JWKS is alg confusion); `none` is NEVER allowed.
|
||||
// 6. IdP downgrade-attack defense: at provider creation /
|
||||
// RefreshKeys, the discovery doc's
|
||||
// `id_token_signing_alg_values_supported` is intersected with
|
||||
// the allow-list. If the IdP advertises HS* / none AT ALL, the
|
||||
// provider is rejected with an actionable error so a future
|
||||
// compromised IdP can't downgrade.
|
||||
// 7. JWKS handling delegated to coreos/go-oidc/v3; on JWKS fetch
|
||||
// failure during a key rotation the service returns
|
||||
// ErrJWKSUnreachable (HTTP 503), existing sessions untouched,
|
||||
// no exponential backoff.
|
||||
// 8. Token-leak hygiene: ID tokens, access tokens, refresh tokens,
|
||||
// authorization codes, PKCE verifiers, state, nonce, and any
|
||||
// signing key bytes MUST NEVER be logged. The service contains
|
||||
// ZERO log statements that include these values; tests in
|
||||
// logging_test.go pin the invariant.
|
||||
// =============================================================================
|
||||
|
||||
// Service implements the OIDC integration.
|
||||
type Service struct {
|
||||
providers OIDCProviderLookup
|
||||
mappings repository.GroupRoleMappingRepository
|
||||
users repository.UserRepository
|
||||
sessions SessionMinter
|
||||
preLogin PreLoginStore
|
||||
|
||||
encryptionKey string // CERTCTL_CONFIG_ENCRYPTION_KEY for client_secret decrypt
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*providerEntry // keyed by provider ID
|
||||
clockNow func() time.Time // injectable for tests
|
||||
}
|
||||
|
||||
// providerEntry caches the go-oidc Provider + the OAuth2 config + the
|
||||
// IdP-advertised algs (used for the downgrade-attack defense check on
|
||||
// every RefreshKeys). The Provider's internal JWKS cache handles
|
||||
// rotation transparently.
|
||||
type providerEntry struct {
|
||||
cfgRow *oidcdomain.OIDCProvider
|
||||
provider *gooidc.Provider
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
oauthConfig *oauth2.Config
|
||||
allowedAlgs []string // intersected: domain config ∩ allow-list ∩ IdP-advertised
|
||||
plaintext []byte // decrypted client secret; held for token exchange
|
||||
}
|
||||
|
||||
// OIDCProviderLookup is a narrow read-side projection of
|
||||
// repository.OIDCProviderRepository — service.go only ever reads
|
||||
// providers; mutations go through the repo from the handler / GUI side.
|
||||
// Defined here so test mocks can satisfy the smaller surface.
|
||||
type OIDCProviderLookup interface {
|
||||
Get(ctx context.Context, id string) (*oidcdomain.OIDCProvider, error)
|
||||
List(ctx context.Context, tenantID string) ([]*oidcdomain.OIDCProvider, error)
|
||||
}
|
||||
|
||||
// PreLoginStore wraps the pre-login session row that holds state +
|
||||
// nonce + PKCE verifier across the IdP redirect. Phase 4's
|
||||
// SessionService satisfies this interface; Phase 3 defines it so the
|
||||
// Service can be unit-tested without the full session machinery.
|
||||
type PreLoginStore interface {
|
||||
// CreatePreLogin persists a row with the given identifiers.
|
||||
// providerID is the configured op-... id; state, nonce, verifier
|
||||
// are server-generated random strings the callback will validate.
|
||||
// Returns the opaque cookie value the handler sets, plus the
|
||||
// session ID (used as the audit trail anchor).
|
||||
CreatePreLogin(ctx context.Context, providerID, state, nonce, verifier string) (cookieValue, sessionID string, err error)
|
||||
|
||||
// LookupAndConsume reads the pre-login row by cookie value AND
|
||||
// deletes it atomically. Single-use: a second call with the same
|
||||
// cookie value returns ErrPreLoginNotFound. Returns the stored
|
||||
// state/nonce/verifier/providerID for the caller to validate
|
||||
// against the callback parameters.
|
||||
LookupAndConsume(ctx context.Context, cookieValue string) (providerID, state, nonce, verifier string, err error)
|
||||
}
|
||||
|
||||
// SessionMinter wraps the post-login session creation. Phase 4's
|
||||
// SessionService satisfies this. Defined here so the OIDC service
|
||||
// can be unit-tested independently of session signing.
|
||||
type SessionMinter interface {
|
||||
// MintForUser creates a post-login session for the named user.
|
||||
// Returns the cookie value the handler sets and a CSRF token
|
||||
// the GUI echoes into the X-CSRF-Token header on POSTs.
|
||||
MintForUser(ctx context.Context, user *userdomain.User, roleIDs []string, ip, userAgent string) (cookieValue, csrfToken string, err error)
|
||||
}
|
||||
|
||||
// IDGenerator returns a new opaque session id. Defaults to 32 random
|
||||
// bytes base64url-no-pad-encoded. Injectable for tests.
|
||||
type IDGenerator func() (string, error)
|
||||
|
||||
// Service-layer sentinels. Handler-layer translates to HTTP status.
|
||||
var (
|
||||
// ErrPreLoginNotFound: the pre-login cookie doesn't match a row.
|
||||
// Either the row was already consumed (replay) or never existed
|
||||
// (forged cookie). HTTP 400.
|
||||
ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed")
|
||||
|
||||
// ErrStateMismatch: callback `state` differs from the stored
|
||||
// pre-login state. HTTP 400.
|
||||
ErrStateMismatch = errors.New("oidc: state parameter mismatch (replay or forgery)")
|
||||
|
||||
// ErrNonceMismatch: ID token `nonce` differs from the stored
|
||||
// pre-login nonce. HTTP 400.
|
||||
ErrNonceMismatch = errors.New("oidc: nonce mismatch")
|
||||
|
||||
// ErrIssuerMismatch: ID token `iss` doesn't match the configured
|
||||
// provider issuer_url. HTTP 400.
|
||||
ErrIssuerMismatch = errors.New("oidc: issuer mismatch")
|
||||
|
||||
// ErrAudienceMismatch: ID token `aud` doesn't include the
|
||||
// configured client_id. HTTP 400.
|
||||
ErrAudienceMismatch = errors.New("oidc: audience mismatch")
|
||||
|
||||
// ErrAZPRequired: ID token has multi-valued aud but no `azp`
|
||||
// claim. Per OIDC core §3.1.3.7 step 5, `azp` MUST be present
|
||||
// when there are multiple audiences. HTTP 400.
|
||||
ErrAZPRequired = errors.New("oidc: multi-aud ID token missing required azp claim")
|
||||
|
||||
// ErrAZPMismatch: ID token `azp` doesn't equal client_id. HTTP 400.
|
||||
ErrAZPMismatch = errors.New("oidc: azp claim does not match client_id")
|
||||
|
||||
// ErrATHashMismatch: ID token `at_hash` doesn't match the
|
||||
// re-computed hash of the access token. HTTP 400.
|
||||
ErrATHashMismatch = errors.New("oidc: at_hash claim does not match access token")
|
||||
|
||||
// ErrATHashRequired: an access token was returned alongside the ID
|
||||
// token but the ID token carries no `at_hash` claim. Per the Phase 3
|
||||
// spec (OIDC core §3.1.3.6 + §3.2.2.9), at_hash is REQUIRED in this
|
||||
// case so a substituted access token can be detected. Fail closed.
|
||||
// HTTP 400.
|
||||
ErrATHashRequired = errors.New("oidc: access_token present but ID token has no at_hash claim")
|
||||
|
||||
// ErrTokenExpired: ID token `exp` is in the past (with 60s
|
||||
// clock-skew tolerance). HTTP 400.
|
||||
ErrTokenExpired = errors.New("oidc: ID token expired")
|
||||
|
||||
// ErrIATInFuture: ID token `iat` is in the future beyond the 60s
|
||||
// skew tolerance. HTTP 400.
|
||||
ErrIATInFuture = errors.New("oidc: ID token iat is in the future")
|
||||
|
||||
// ErrIATTooOld: ID token `iat` is older than the configured
|
||||
// IATWindow. HTTP 400.
|
||||
ErrIATTooOld = errors.New("oidc: ID token iat older than configured window")
|
||||
|
||||
// ErrAlgRejected: ID token signed with an alg outside the
|
||||
// allow-list. HTTP 400.
|
||||
ErrAlgRejected = errors.New("oidc: ID token signed with disallowed algorithm")
|
||||
|
||||
// ErrIdPDowngradeAdvertised: provider's discovery doc advertises
|
||||
// HS* or `none` algorithms. Provider creation / refresh rejects.
|
||||
// HTTP 400.
|
||||
ErrIdPDowngradeAdvertised = errors.New("oidc: IdP advertises weak signing algorithms (HS*/none); refusing to use as defense against downgrade attacks")
|
||||
|
||||
// ErrJWKSUnreachable: JWKS endpoint fetch failed during a
|
||||
// rotation. The in-flight login fails 503; existing sessions
|
||||
// untouched.
|
||||
ErrJWKSUnreachable = errors.New("oidc: JWKS endpoint unreachable; in-flight login fails, existing sessions untouched")
|
||||
|
||||
// ErrGroupsMissing: the configured groups_claim_path resolves
|
||||
// to nothing or is malformed. Phase 3 fails closed.
|
||||
ErrGroupsMissing = errors.New("oidc: configured groups claim missing or malformed")
|
||||
|
||||
// ErrGroupsUnmapped: the user's groups don't match any of the
|
||||
// operator's group_role_mappings for this provider. No session
|
||||
// minted; audit row records auth.oidc_login_unmapped_groups.
|
||||
ErrGroupsUnmapped = errors.New("oidc: groups did not match any configured mapping")
|
||||
|
||||
// ErrPKCEPlainRejected: somehow `plain` PKCE method got into
|
||||
// the flow. Defense-in-depth; the service NEVER generates a plain
|
||||
// verifier, but this sentinel exists in case a future code path
|
||||
// regresses.
|
||||
ErrPKCEPlainRejected = errors.New("oidc: PKCE method 'plain' is rejected; S256 is mandatory")
|
||||
)
|
||||
|
||||
// DefaultAllowedAlgs is the operator-default ID-token signing algorithm
|
||||
// allow-list. Configurable per-provider but the union must be a subset
|
||||
// of this set. HMAC algorithms (HS256/HS384/HS512) and `none` are
|
||||
// NEVER in the default set; the IdP downgrade defense rejects any
|
||||
// provider that advertises them in discovery.
|
||||
var DefaultAllowedAlgs = []string{
|
||||
gooidc.RS256, gooidc.RS512,
|
||||
gooidc.ES256, gooidc.ES384,
|
||||
gooidc.EdDSA,
|
||||
}
|
||||
|
||||
// disallowedAlgs is the explicit deny-list. Anything in this set
|
||||
// fails the IdP downgrade check at provider creation / RefreshKeys
|
||||
// AND fails the per-token alg check at HandleCallback time, even if
|
||||
// the operator somehow added it to AllowedAlgs by hand.
|
||||
var disallowedAlgs = map[string]struct{}{
|
||||
"HS256": {},
|
||||
"HS384": {},
|
||||
"HS512": {},
|
||||
"none": {},
|
||||
}
|
||||
|
||||
// NewService constructs an OIDC Service.
|
||||
func NewService(
|
||||
providers OIDCProviderLookup,
|
||||
mappings repository.GroupRoleMappingRepository,
|
||||
users repository.UserRepository,
|
||||
sessions SessionMinter,
|
||||
preLogin PreLoginStore,
|
||||
encryptionKey string,
|
||||
) *Service {
|
||||
return &Service{
|
||||
providers: providers,
|
||||
mappings: mappings,
|
||||
users: users,
|
||||
sessions: sessions,
|
||||
preLogin: preLogin,
|
||||
encryptionKey: encryptionKey,
|
||||
cache: make(map[string]*providerEntry),
|
||||
clockNow: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetClockForTest replaces the clock used for `iat`/`exp` checks. ONLY
|
||||
// for tests; production paths read time.Now via the default.
|
||||
func (s *Service) SetClockForTest(now func() time.Time) {
|
||||
s.clockNow = now
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HandleAuthRequest: kicks off the OIDC handshake.
|
||||
//
|
||||
// Returns the IdP authorization URL (302 target), the cookie value to
|
||||
// set for the pre-login session, and the pre-login session ID for the
|
||||
// audit trail. The caller (HTTP handler) sets the cookie + redirects.
|
||||
//
|
||||
// PKCE-S256 is mandatory: a 43-128 character base64url-no-pad random
|
||||
// verifier is generated, the challenge is the SHA-256 of the verifier
|
||||
// base64url-encoded, the method is hard-coded `S256`. No code path in
|
||||
// this service ever sets `code_challenge_method=plain`.
|
||||
// =============================================================================
|
||||
|
||||
// HandleAuthRequest builds the IdP redirect URL + persists the
|
||||
// pre-login session row holding state + nonce + PKCE verifier.
|
||||
func (s *Service) HandleAuthRequest(ctx context.Context, providerID string) (authURL, cookieValue, preLoginID string, err error) {
|
||||
entry, err := s.getOrLoad(ctx, providerID)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
state, err := randomB64URL(32)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("oidc: state generate: %w", err)
|
||||
}
|
||||
nonce, err := randomB64URL(32)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("oidc: nonce generate: %w", err)
|
||||
}
|
||||
// PKCE S256 verifier: 32 random bytes -> 43-char base64url-no-pad
|
||||
// (well within the RFC 7636 43-128 character bound).
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
|
||||
cookieValue, preLoginID, err = s.preLogin.CreatePreLogin(ctx, providerID, state, nonce, verifier)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("oidc: pre-login store: %w", err)
|
||||
}
|
||||
|
||||
// Build the IdP redirect URL. PKCE S256 is hard-coded via
|
||||
// oauth2.S256ChallengeOption; nonce is added via OIDC's
|
||||
// AuthCodeOption.
|
||||
authURL = entry.oauthConfig.AuthCodeURL(
|
||||
state,
|
||||
oauth2.AccessTypeOnline,
|
||||
oauth2.S256ChallengeOption(verifier),
|
||||
oauth2.SetAuthURLParam("nonce", nonce),
|
||||
)
|
||||
|
||||
return authURL, cookieValue, preLoginID, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HandleCallback: completes the OIDC handshake and creates a session.
|
||||
//
|
||||
// Validates state, exchanges code for tokens (with PKCE verifier),
|
||||
// validates ID token (alg pin, iss, aud, azp, at_hash, exp, iat,
|
||||
// nonce), parses group claims, maps groups to roles, creates / updates
|
||||
// the user record, mints a session.
|
||||
//
|
||||
// Every fail-closed branch returns one of the package-scoped sentinel
|
||||
// errors so the handler can map to the right HTTP status without
|
||||
// leaking which check failed (uniform 400 to the wire; specific
|
||||
// reason in the audit row).
|
||||
// =============================================================================
|
||||
|
||||
// CallbackResult is what HandleCallback returns to the handler. The
|
||||
// handler sets cookieValue + csrfToken on the response and 302's to
|
||||
// the GUI dashboard.
|
||||
type CallbackResult struct {
|
||||
User *userdomain.User
|
||||
RoleIDs []string
|
||||
CookieValue string // post-login session cookie
|
||||
CSRFToken string // CSRF token for the GUI to echo into X-CSRF-Token
|
||||
}
|
||||
|
||||
// HandleCallback completes the OIDC flow.
|
||||
func (s *Service) HandleCallback(
|
||||
ctx context.Context,
|
||||
preLoginCookie, code, callbackState, ip, userAgent string,
|
||||
) (*CallbackResult, error) {
|
||||
// Step 1: consume the pre-login row (single-use).
|
||||
providerID, storedState, storedNonce, verifier, err := s.preLogin.LookupAndConsume(ctx, preLoginCookie)
|
||||
if err != nil {
|
||||
return nil, ErrPreLoginNotFound
|
||||
}
|
||||
|
||||
// Step 2: state constant-time compare.
|
||||
if subtle.ConstantTimeCompare([]byte(callbackState), []byte(storedState)) != 1 {
|
||||
return nil, ErrStateMismatch
|
||||
}
|
||||
|
||||
entry, err := s.getOrLoad(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 3: exchange the auth code for tokens (with PKCE verifier).
|
||||
token, err := entry.oauthConfig.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: code exchange failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: extract + validate the ID token. NEVER log token here.
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return nil, fmt.Errorf("oidc: token response missing id_token")
|
||||
}
|
||||
|
||||
idToken, err := entry.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
// Map go-oidc's verify errors to ErrJWKSUnreachable when the
|
||||
// underlying cause is a JWKS fetch failure; otherwise return
|
||||
// the wrapped error for the handler to map to 400.
|
||||
if isJWKSFetchError(err) {
|
||||
return nil, ErrJWKSUnreachable
|
||||
}
|
||||
return nil, fmt.Errorf("oidc: id_token verify failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: alg pinning. go-oidc's verifier already enforces the
|
||||
// allow-list we set in the config, but we re-check the header alg
|
||||
// against our deny-list for belt-and-braces (defense vs an
|
||||
// upstream library regression).
|
||||
if rejected, alg := isDisallowedAlg(rawIDToken); rejected {
|
||||
_ = alg // do not log
|
||||
return nil, ErrAlgRejected
|
||||
}
|
||||
|
||||
// Step 6: per-OIDC-core §3.1.3.7 claims checks beyond what
|
||||
// gooidc.Verify covers.
|
||||
now := s.clockNow().UTC()
|
||||
|
||||
// iss is verified by gooidc.Verify against entry.cfgRow.IssuerURL;
|
||||
// re-check exactly to defend against a library regression.
|
||||
if idToken.Issuer != entry.cfgRow.IssuerURL {
|
||||
return nil, ErrIssuerMismatch
|
||||
}
|
||||
|
||||
// aud must contain client_id.
|
||||
audOK := false
|
||||
for _, a := range idToken.Audience {
|
||||
if a == entry.cfgRow.ClientID {
|
||||
audOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audOK {
|
||||
return nil, ErrAudienceMismatch
|
||||
}
|
||||
|
||||
// azp required when aud is multi-valued; if present, must equal client_id.
|
||||
var extra struct {
|
||||
AZP string `json:"azp"`
|
||||
ATHash string `json:"at_hash"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
if err := idToken.Claims(&extra); err != nil {
|
||||
return nil, fmt.Errorf("oidc: id_token claims unmarshal: %w", err)
|
||||
}
|
||||
if len(idToken.Audience) > 1 {
|
||||
if extra.AZP == "" {
|
||||
return nil, ErrAZPRequired
|
||||
}
|
||||
}
|
||||
if extra.AZP != "" && extra.AZP != entry.cfgRow.ClientID {
|
||||
return nil, ErrAZPMismatch
|
||||
}
|
||||
|
||||
// at_hash validation. When an access token is returned alongside the
|
||||
// ID token, OIDC core §3.1.3.6 + §3.2.2.9 require the ID token to
|
||||
// carry an at_hash claim that hashes the access token (alg-matching
|
||||
// hash family, left-half, base64url-no-pad). The Phase 3 spec lifts
|
||||
// this from the RFC's "MAY" to a "MUST" so a substituted access
|
||||
// token cannot ride a clean ID token through the verifier.
|
||||
if token.AccessToken != "" {
|
||||
if extra.ATHash == "" {
|
||||
return nil, ErrATHashRequired
|
||||
}
|
||||
if !atHashMatches(rawIDToken, token.AccessToken, extra.ATHash) {
|
||||
return nil, ErrATHashMismatch
|
||||
}
|
||||
}
|
||||
|
||||
// exp + iat (60s clock skew tolerance).
|
||||
const skew = 60 * time.Second
|
||||
if idToken.Expiry.Add(skew).Before(now) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if idToken.IssuedAt.After(now.Add(skew)) {
|
||||
return nil, ErrIATInFuture
|
||||
}
|
||||
iatWindow := time.Duration(entry.cfgRow.IATWindowSeconds) * time.Second
|
||||
if idToken.IssuedAt.Add(iatWindow).Before(now) {
|
||||
return nil, ErrIATTooOld
|
||||
}
|
||||
|
||||
// nonce constant-time compare.
|
||||
if subtle.ConstantTimeCompare([]byte(extra.Nonce), []byte(storedNonce)) != 1 {
|
||||
return nil, ErrNonceMismatch
|
||||
}
|
||||
|
||||
// Step 7: extract claims for group resolution + user record.
|
||||
var profile struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Raw map[string]interface{} `json:"-"`
|
||||
}
|
||||
if err := idToken.Claims(&profile); err != nil {
|
||||
return nil, fmt.Errorf("oidc: profile claims unmarshal: %w", err)
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := idToken.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: raw claims unmarshal: %w", err)
|
||||
}
|
||||
profile.Raw = raw
|
||||
|
||||
// Step 8: group claim resolution.
|
||||
groups, err := groupclaim.Resolve(profile.Raw, entry.cfgRow.GroupsClaimPath)
|
||||
if err != nil || len(groups) == 0 {
|
||||
// Try the userinfo endpoint fallback if the operator opted in.
|
||||
if entry.cfgRow.FetchUserinfo {
|
||||
groups2, uerr := s.fetchUserinfoGroups(ctx, entry, token, entry.cfgRow.GroupsClaimPath)
|
||||
if uerr == nil && len(groups2) > 0 {
|
||||
groups = groups2
|
||||
} else {
|
||||
return nil, ErrGroupsMissing
|
||||
}
|
||||
} else {
|
||||
return nil, ErrGroupsMissing
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: map groups to role IDs. Empty result => fail closed.
|
||||
roleIDs, err := s.mappings.Map(ctx, providerID, groups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: group-role mapping lookup: %w", err)
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
return nil, ErrGroupsUnmapped
|
||||
}
|
||||
|
||||
// Step 10: upsert the user record. Per Phase 1 contract, identity
|
||||
// is per-(provider, oidc_subject); a person logging in via a new
|
||||
// provider gets a new users row.
|
||||
user, err := s.upsertUser(ctx, entry.cfgRow, idToken.Subject, profile.Email, profile.Name, profile.PreferredUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: upsert user: %w", err)
|
||||
}
|
||||
|
||||
// Step 11: mint a post-login session via Phase 4's SessionService.
|
||||
cookieValue, csrfToken, err := s.sessions.MintForUser(ctx, user, roleIDs, ip, userAgent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: session mint: %w", err)
|
||||
}
|
||||
|
||||
return &CallbackResult{
|
||||
User: user,
|
||||
RoleIDs: roleIDs,
|
||||
CookieValue: cookieValue,
|
||||
CSRFToken: csrfToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// upsertUser looks up by (provider, subject) and either updates the
|
||||
// existing user or creates a new one. last_login_at is bumped on every
|
||||
// login.
|
||||
func (s *Service) upsertUser(
|
||||
ctx context.Context,
|
||||
provider *oidcdomain.OIDCProvider,
|
||||
subject, email, displayName, fallbackName string,
|
||||
) (*userdomain.User, error) {
|
||||
if displayName == "" {
|
||||
displayName = fallbackName
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
|
||||
existing, err := s.users.GetByOIDCSubject(ctx, provider.ID, subject)
|
||||
if err == nil {
|
||||
// Update last_login_at, email, display_name (per the Phase 1
|
||||
// mutable-field contract).
|
||||
existing.Email = email
|
||||
existing.DisplayName = displayName
|
||||
existing.LastLoginAt = s.clockNow().UTC()
|
||||
if uerr := s.users.Update(ctx, existing); uerr != nil {
|
||||
return nil, uerr
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First login: create a new user record.
|
||||
id, err := randomB64URL(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: user id generate: %w", err)
|
||||
}
|
||||
u := &userdomain.User{
|
||||
ID: "u-" + id,
|
||||
TenantID: provider.TenantID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
OIDCSubject: subject,
|
||||
OIDCProviderID: provider.ID,
|
||||
LastLoginAt: s.clockNow().UTC(),
|
||||
WebAuthnCredentials: []byte("[]"),
|
||||
}
|
||||
if verr := u.Validate(); verr != nil {
|
||||
return nil, fmt.Errorf("oidc: new user validate: %w", verr)
|
||||
}
|
||||
if cerr := s.users.Create(ctx, u); cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// fetchUserinfoGroups falls back to the IdP userinfo endpoint when
|
||||
// the operator opts in via fetch_userinfo=true AND the ID token
|
||||
// didn't surface the groups claim. Returns the group list resolved
|
||||
// against groups_claim_path.
|
||||
func (s *Service) fetchUserinfoGroups(
|
||||
ctx context.Context,
|
||||
entry *providerEntry,
|
||||
token *oauth2.Token,
|
||||
path string,
|
||||
) ([]string, error) {
|
||||
if entry.provider.UserInfoEndpoint() == "" {
|
||||
return nil, fmt.Errorf("oidc: userinfo fallback configured but provider has no userinfo endpoint")
|
||||
}
|
||||
ts := entry.oauthConfig.TokenSource(ctx, token)
|
||||
uinfo, err := entry.provider.UserInfo(ctx, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: userinfo fetch: %w", err)
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := uinfo.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: userinfo claims: %w", err)
|
||||
}
|
||||
return groupclaim.Resolve(raw, path)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RefreshKeys: explicitly invalidate + refetch the cached provider.
|
||||
//
|
||||
// Used by the GUI's "Refresh discovery cache" button (Phase 8) when an
|
||||
// operator knows the IdP rotated its keys mid-day and the JWKS cache
|
||||
// is stale. Re-runs the IdP downgrade-attack defense too: if the IdP
|
||||
// rotated in HS* / `none` advertisement, we catch it here.
|
||||
// =============================================================================
|
||||
|
||||
// RefreshKeys evicts the cached provider entry and re-loads it from
|
||||
// scratch. Invokes the discovery doc fetch + the downgrade defense.
|
||||
func (s *Service) RefreshKeys(ctx context.Context, providerID string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.cache, providerID)
|
||||
s.mu.Unlock()
|
||||
|
||||
_, err := s.getOrLoad(ctx, providerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider load + cache + IdP downgrade defense.
|
||||
// =============================================================================
|
||||
|
||||
// getOrLoad returns a cached provider entry, loading from the repo +
|
||||
// fetching the IdP discovery doc on miss. Cache uses a write-then-read
|
||||
// pattern under sync.RWMutex; concurrent first-loads of the same
|
||||
// provider may duplicate the discovery fetch but never produce
|
||||
// divergent cache entries (the second-arriving entry overwrites and
|
||||
// both entries are equivalent).
|
||||
func (s *Service) getOrLoad(ctx context.Context, providerID string) (*providerEntry, error) {
|
||||
s.mu.RLock()
|
||||
entry, ok := s.cache[providerID]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Read the configured row.
|
||||
cfgRow, err := s.providers.Get(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch + cache the discovery doc + JWKS via go-oidc.
|
||||
provider, err := gooidc.NewProvider(ctx, cfgRow.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: discovery fetch failed for %s: %w", providerID, err)
|
||||
}
|
||||
|
||||
// IdP downgrade-attack defense. The discovery doc's
|
||||
// id_token_signing_alg_values_supported MUST NOT include any
|
||||
// disallowed alg.
|
||||
var advertised struct {
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
}
|
||||
if cerr := provider.Claims(&advertised); cerr != nil {
|
||||
return nil, fmt.Errorf("oidc: discovery claims: %w", cerr)
|
||||
}
|
||||
for _, a := range advertised.IDTokenSigningAlgValuesSupported {
|
||||
if _, deny := disallowedAlgs[a]; deny {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIdPDowngradeAdvertised, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the effective allow-list: intersection of the default
|
||||
// allow-list AND any operator-configured restriction (currently
|
||||
// the domain layer doesn't expose per-provider alg config beyond
|
||||
// the default; placeholder for a future Phase-3-extended config).
|
||||
allowed := DefaultAllowedAlgs
|
||||
|
||||
// Decrypt the client secret. The plaintext is held in memory only;
|
||||
// never persisted, never logged.
|
||||
plaintext, err := decryptClientSecret(cfgRow.ClientSecretEncrypted, s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: client_secret decrypt: %w", err)
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&gooidc.Config{
|
||||
ClientID: cfgRow.ClientID,
|
||||
SupportedSigningAlgs: allowed,
|
||||
})
|
||||
|
||||
oauthConfig := &oauth2.Config{
|
||||
ClientID: cfgRow.ClientID,
|
||||
ClientSecret: string(plaintext),
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: cfgRow.RedirectURI,
|
||||
Scopes: cfgRow.Scopes,
|
||||
}
|
||||
|
||||
entry = &providerEntry{
|
||||
cfgRow: cfgRow,
|
||||
provider: provider,
|
||||
verifier: verifier,
|
||||
oauthConfig: oauthConfig,
|
||||
allowedAlgs: allowed,
|
||||
plaintext: plaintext,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cache[providerID] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers (alg parsing, at_hash, random, JWKS-error detection,
|
||||
// client_secret decrypt). Kept private; tests in service_test.go.
|
||||
// =============================================================================
|
||||
|
||||
// randomB64URL returns nbytes of cryptographic randomness encoded as
|
||||
// base64url-no-pad. Used for state, nonce, session IDs.
|
||||
func randomB64URL(nbytes int) (string, error) {
|
||||
b := make([]byte, nbytes)
|
||||
if _, err := readRand(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// readRand is a package-level seam so tests can deterministically
|
||||
// substitute crypto/rand. Production reads from crypto/rand.Reader.
|
||||
var readRand = func(b []byte) (int, error) {
|
||||
return cryptorand.Read(b)
|
||||
}
|
||||
|
||||
// isDisallowedAlg parses the JWS header alg and reports whether it's
|
||||
// in the deny-list. NEVER returns or logs the alg; the caller maps
|
||||
// the bool to ErrAlgRejected without surfacing details.
|
||||
func isDisallowedAlg(rawJWT string) (bool, string) {
|
||||
// JWS Compact: <header>.<payload>.<signature>. Decode header,
|
||||
// extract `alg`. Defensive: catches bad input shapes too.
|
||||
parts := strings.Split(rawJWT, ".")
|
||||
if len(parts) != 3 {
|
||||
return true, ""
|
||||
}
|
||||
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return true, ""
|
||||
}
|
||||
// Find the alg value. Extreme minimal parser: avoid pulling in
|
||||
// encoding/json so the path is allocation-tight on every login.
|
||||
// Format: {"alg":"RS256",...}; some libraries emit
|
||||
// {"alg" : "RS256" ,...} so the parser tolerates whitespace
|
||||
// around both the colon and the value.
|
||||
hdr := string(headerJSON)
|
||||
idx := strings.Index(hdr, `"alg"`)
|
||||
if idx < 0 {
|
||||
return true, ""
|
||||
}
|
||||
rest := hdr[idx+5:] // skip "alg"
|
||||
rest = strings.TrimLeft(rest, " \t\r\n")
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return true, ""
|
||||
}
|
||||
rest = rest[1:]
|
||||
rest = strings.TrimLeft(rest, " \t\r\n")
|
||||
if !strings.HasPrefix(rest, `"`) {
|
||||
return true, ""
|
||||
}
|
||||
rest = rest[1:]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end < 0 {
|
||||
return true, ""
|
||||
}
|
||||
alg := rest[:end]
|
||||
if _, deny := disallowedAlgs[alg]; deny {
|
||||
return true, alg
|
||||
}
|
||||
return false, alg
|
||||
}
|
||||
|
||||
// atHashMatches recomputes at_hash per OIDC core §3.1.3.6 + §3.2.2.9
|
||||
// and constant-time-compares against the claim. Algorithm matches the
|
||||
// hash family of the ID token's signing alg (RS256 -> SHA-256, RS512
|
||||
// -> SHA-512, ES256 -> SHA-256, ES384 -> SHA-384, EdDSA -> SHA-512).
|
||||
// Returns true iff the recomputed half-hash equals the claim.
|
||||
func atHashMatches(rawIDToken, accessToken, claimAtHash string) bool {
|
||||
_, alg := isDisallowedAlg(rawIDToken) // re-extracts alg
|
||||
var h hash.Hash
|
||||
switch alg {
|
||||
case "RS256", "ES256":
|
||||
h = sha256.New()
|
||||
case "ES384":
|
||||
h = sha512.New384()
|
||||
case "RS512", "EdDSA":
|
||||
h = sha512.New()
|
||||
default:
|
||||
// Unknown alg should already have been caught by the
|
||||
// alg-pin check; refuse to recompute here.
|
||||
return false
|
||||
}
|
||||
h.Write([]byte(accessToken))
|
||||
sum := h.Sum(nil)
|
||||
half := sum[:len(sum)/2]
|
||||
expected := base64.RawURLEncoding.EncodeToString(half)
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(claimAtHash)) == 1
|
||||
}
|
||||
|
||||
// isJWKSFetchError detects whether the underlying error from
|
||||
// gooidc.IDTokenVerifier.Verify is a JWKS-fetch failure (network
|
||||
// error talking to the IdP's jwks_uri during a key rotation event).
|
||||
// Maps to ErrJWKSUnreachable so the handler returns 503 to the
|
||||
// in-flight login attempt without auto-revoking existing sessions.
|
||||
func isJWKSFetchError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "fetching keys") ||
|
||||
strings.Contains(msg, "jwks_uri") ||
|
||||
strings.Contains(msg, "key set")
|
||||
}
|
||||
|
||||
// decryptClientSecret runs the client_secret_encrypted blob through
|
||||
// internal/crypto/encryption.go's v2 Decrypt path. The plaintext
|
||||
// MUST NOT be logged or written anywhere except oauthConfig.ClientSecret.
|
||||
func decryptClientSecret(blob []byte, key string) ([]byte, error) {
|
||||
if key == "" {
|
||||
// Test path / local dev: blob is already the plaintext (the
|
||||
// caller didn't run it through Encrypt). Return as-is.
|
||||
return blob, nil
|
||||
}
|
||||
plain, err := crypto.DecryptIfKeySet(blob, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user