generated from coulomb/repo-seed
feat: implement T09, T15, T21 — userinfo endpoint, LLDAP export, negative tests
- T09: /userinfo with RS256 JWT validation, scope-filtered claims - T15: LLDAP→canonical export tool with validation, migration_event telemetry - T21: Negative test suite (Scenario D) — all 7 unsupported features verified All go tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
185
src/internal/server/oidc/userinfo.go
Normal file
185
src/internal/server/oidc/userinfo.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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 <token> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user