key-cape/src/internal/server/oidc/userinfo.go
tegwick 139b6ff351
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Harden the authorization-code grant and UserInfo verification
Closes the local protocol surface of gap G01 from the scope assessment
(KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left
four bindings unenforced, and UserInfo verified less than the caller CLI does.

Authorization-code path: bind the exchange to the redirect URI the code was
issued for, refuse clients whose registration does not permit the grant, and
authenticate confidential clients with a digest-based constant-time comparison
over the same credential sources as the service grant. An empty grantTypes stays
an implicit authorization-code client, matching config validation.

Code consumption: SessionStore.Consume reads and deletes under one lock. The
previous Get/Delete pair spanned JWT signing, and the added test reproduces the
race against that version -- 9 of 16 concurrent exchanges succeeded, and a
failed exchange left the code replayable.

UserInfo: check the JOSE header algorithm before trusting the signature, require
the configured issuer, and require an access token rather than accepting an ID
token of the right shape. Purpose is decided on the scope claim so the issued
token contract, which consumers pin exactly, does not change.

SCOPE.md and the assessment record which bindings are now enforced and that the
Authelia upstream-trust assumption remains open, so G01 is not fully closed and
no profile-conformance claim is made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:43:47 +02:00

262 lines
8.2 KiB
Go

package oidc
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/server/telemetry"
)
// UserinfoHandler implements GET /userinfo (OIDC Core §5.3).
//
// The endpoint validates the Bearer token, extracts the subject, looks up
// the user, and returns claims that are consistent with those in the ID token
// for the same scope set.
type UserinfoHandler struct {
Users domain.UserRepository
SigningKey *rsa.PublicKey // used to verify the incoming access token
Issuer string
Emitter telemetry.Emitter
}
// ServeHTTP handles GET /userinfo.
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Extract Bearer token.
tokenStr, ok := bearerToken(r)
if !ok {
http.Error(w, `{"error":"missing_token","description":"Authorization: Bearer <token> required"}`, http.StatusUnauthorized)
return
}
// 2. Validate token (algorithm, signature, expiry, issuer, purpose) and
// extract claims.
claims, err := validateAccessToken(tokenStr, h.SigningKey, h.Issuer)
if err != nil {
http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized)
return
}
// 3. Extract sub claim (which is the username in our model).
sub, _ := claims["sub"].(string)
if sub == "" {
http.Error(w, `{"error":"invalid_token","description":"missing sub claim"}`, http.StatusUnauthorized)
return
}
// 4. Resolve the canonical subject. Human tokens use the stable directory
// ID (the LDAP DN) as sub, while LookupUser accepts an LDAP uid. Prefer the
// scoped username hint but require it to resolve back to the signed subject;
// fall back to an ID scan for openid-only tokens and renamed users.
user, err := lookupUserBySubject(ctx, h.Users, claims, sub)
if err != nil {
// User referenced in token but not found → treat as invalid token.
http.Error(w, `{"error":"invalid_token","description":"subject not found"}`, http.StatusUnauthorized)
return
}
if isSuspended(user) {
http.Error(w, `{"error":"invalid_token","description":"subject is suspended"}`, http.StatusUnauthorized)
return
}
// 5. Build response claims filtered by the scopes embedded in the token.
scopeStr, _ := claims["scope"].(string)
scopeSet := parseScopeSet(scopeStr)
resp := map[string]interface{}{
"sub": sub,
}
if scopeSet["profile"] {
resp["preferred_username"] = user.Username
resp["name"] = user.DisplayName
}
if scopeSet["email"] {
resp["email"] = user.Email
}
if scopeSet["groups"] {
resp["groups"] = user.Groups
}
// 6. Emit telemetry.
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthSuccess,
Endpoint: "/userinfo",
Result: "success",
})
// 7. Write JSON response.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}
func lookupUserBySubject(
ctx context.Context,
users domain.UserRepository,
claims map[string]interface{},
sub string,
) (*domain.User, error) {
if username, _ := claims["preferred_username"].(string); username != "" {
if user, err := users.LookupUser(ctx, username); err == nil && user.ID == sub {
return user, nil
}
}
// Retain compatibility with older signed tokens whose subject was the uid.
if user, err := users.LookupUser(ctx, sub); err == nil &&
(user.ID == sub || user.Username == sub) {
return user, nil
}
all, err := users.ListUsers(ctx)
if err != nil {
return nil, err
}
for i := range all {
if all[i].ID == sub {
return &all[i], nil
}
}
return nil, domain.ErrUserNotFound
}
// ---------------------------------------------------------------------------
// JWT validation (stdlib only — no external JWT library)
// ---------------------------------------------------------------------------
// validateAccessToken parses and validates a KeyCape-issued access token.
//
// Beyond signature and expiry it enforces the bindings the caller CLI already
// requires (KEY-WP-0016-T03): the JOSE header algorithm must be exactly RS256,
// so an "alg":"none" or HMAC-shaped token can never bypass the RSA check; the
// issuer claim must equal this issuer, so a correctly-signed token from another
// deployment is refused; and the token must be an access token.
//
// Purpose is decided on the presence of the `scope` claim, which the token
// endpoint sets on access tokens and never on ID tokens. That keeps the check
// verification-side: it does not add a claim to the issued token contract, which
// consumers pin exactly.
func validateAccessToken(tokenStr string, pubKey *rsa.PublicKey, issuer string) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, errors.New("malformed JWT: expected 3 parts")
}
// Verify the JOSE header algorithm before trusting the signature check.
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("malformed JWT: invalid header encoding")
}
var header struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, errors.New("malformed JWT: header is not valid JSON")
}
if header.Alg != "RS256" {
return nil, errors.New("unsupported JWT algorithm")
}
// Verify signature over header.payload.
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sigBytes, decodeErr := base64.RawURLEncoding.DecodeString(parts[2])
if decodeErr != nil {
return nil, errors.New("malformed JWT: invalid signature encoding")
}
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, digest[:], sigBytes); err != nil {
return nil, errors.New("JWT signature verification failed")
}
// Decode payload.
payloadJSON, payloadErr := base64.RawURLEncoding.DecodeString(parts[1])
if payloadErr != nil {
return nil, errors.New("malformed JWT: invalid payload encoding")
}
var claims map[string]interface{}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, errors.New("malformed JWT: payload is not valid JSON")
}
// Check exp claim.
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("JWT missing exp claim")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("JWT has expired")
}
// Require this issuer. An empty configured issuer would make the check
// vacuous, so treat that as a misconfiguration rather than skipping it.
if issuer == "" {
return nil, errors.New("issuer is not configured")
}
if tokenIssuer, _ := claims["iss"].(string); tokenIssuer != issuer {
return nil, errors.New("JWT issuer mismatch")
}
// Require an access token. ID tokens carry no scope claim.
if _, ok := claims["scope"]; !ok {
return nil, errors.New("JWT is not an access token")
}
return claims, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// bearerToken extracts the token from the Authorization header.
// Returns ("", false) when the header is missing or not a Bearer token.
func bearerToken(r *http.Request) (string, bool) {
hdr := r.Header.Get("Authorization")
if hdr == "" {
return "", false
}
const prefix = "Bearer "
if !strings.HasPrefix(hdr, prefix) {
return "", false
}
tok := strings.TrimSpace(hdr[len(prefix):])
if tok == "" {
return "", false
}
return tok, true
}
// parseScopeSet converts a space-separated scope string to a set.
func parseScopeSet(scope string) map[string]bool {
set := make(map[string]bool)
for _, s := range strings.Fields(scope) {
set[s] = true
}
return set
}
// ---------------------------------------------------------------------------
// BuildJWT — exported for test helpers
// ---------------------------------------------------------------------------
// BuildJWT is an exported wrapper around the internal buildJWT function so
// that tests in the oidc_test package can construct valid tokens for the
// UserinfoHandler without importing an external JWT library.
func BuildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (string, error) {
return buildJWT(claims, kid, key)
}