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 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) }