Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
280 lines
8.7 KiB
Go
280 lines
8.7 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
|
|
Tokens *TokenStatusStore
|
|
ClientConfig map[string]*domain.Client
|
|
}
|
|
|
|
// ServeHTTP handles GET /userinfo.
|
|
func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
if h.Tokens != nil {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if h.Tokens != nil {
|
|
if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !active {
|
|
http.Error(w, `{"error":"invalid_token"}`, 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
|
|
}
|
|
if h.Tokens != nil {
|
|
if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !user.Enabled || !active {
|
|
http.Error(w, `{"error":"invalid_token"}`, 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)
|
|
}
|