package oidc import ( "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 (signature + expiry) and extract claims. claims, err := validateJWT(tokenStr, h.SigningKey) 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. Look up user by sub (sub IS the username per spec §3.1). user, err := h.Users.LookupUser(ctx, 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 } // 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) } // --------------------------------------------------------------------------- // JWT validation (stdlib only — no external JWT library) // --------------------------------------------------------------------------- // validateJWT parses and validates a JWT signed with RS256. // It checks the signature using pubKey and verifies the exp claim. // Returns the parsed claims on success. func validateJWT(tokenStr string, pubKey *rsa.PublicKey) (map[string]interface{}, error) { parts := strings.Split(tokenStr, ".") if len(parts) != 3 { return nil, errors.New("malformed JWT: expected 3 parts") } // Verify signature over header.payload. signingInput := parts[0] + "." + parts[1] digest := sha256.Sum256([]byte(signingInput)) sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != 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, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != 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") } 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) }