// Package oidcstub provides a minimal in-process OIDC provider for testing. // It serves discovery, JWKS, and a /dev/token convenience endpoint from a // single http.Handler that callers can mount on any httptest.Server. // // Usage: // // stub, _ := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"}) // srv := httptest.NewServer(stub.Handler()) // stub.SetIssuer(srv.URL) // tok, _ := stub.SignToken(map[string]any{"sub": "alice", "aud": "myapp"}) // // IV1: imports only stdlib + go.bigb.es/auxilia/culpa. package oidcstub import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "math/big" "net/http" "time" "go.bigb.es/auxilia/culpa" ) const defaultKID = "oidcstub-key-1" // Options configures a Stub. type Options struct { // Issuer is the OIDC issuer URL. Required. Issuer string // Audience is the default audience claim included by Mint. Optional. Audience string // UsernameClaim is the JWT claim set to the sub argument by Mint. Optional. UsernameClaim string // DefaultTTL is the default token lifetime used by Mint and /dev/token. // Defaults to 1 hour when zero. DefaultTTL time.Duration } // Stub is an in-memory OIDC stub. Construct with New. type Stub struct { key *rsa.PrivateKey kid string issuer string audience string usernameClaim string defaultTTL time.Duration } // New creates a ready-to-mount Stub. opts.Issuer must be non-empty. // A fresh 2048-bit RSA signing key is generated on every call (AS3). func New(opts Options) (*Stub, error) { if opts.Issuer == "" { return nil, culpa.WithCode( culpa.New("oidcstub: Options.Issuer is required"), "OIDCSTUB_MISSING_ISSUER", ) } key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, culpa.WithCode( culpa.Wrap(err, "oidcstub: generate RSA key"), "OIDCSTUB_KEY_GEN", ) } ttl := opts.DefaultTTL if ttl == 0 { ttl = time.Hour } return &Stub{ key: key, kid: defaultKID, issuer: opts.Issuer, audience: opts.Audience, usernameClaim: opts.UsernameClaim, defaultTTL: ttl, }, nil } // SetIssuer updates the issuer URL. Call this once after the httptest.Server // has started to swap the placeholder URL for the real one. Calling SetIssuer // after the first token is issued will invalidate already-issued tokens. func (s *Stub) SetIssuer(issuer string) { s.issuer = issuer } // Issuer returns the current issuer URL. func (s *Stub) Issuer() string { return s.issuer } // Handler returns an http.Handler that serves: // // - /.well-known/openid-configuration — OIDC discovery document // - /jwks — JSON Web Key Set // - /dev/token — convenience token endpoint func (s *Stub) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery) mux.HandleFunc("/jwks", s.handleJWKS) mux.HandleFunc("/dev/token", s.handleDevToken) return mux } func (s *Stub) handleDiscovery(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") body := map[string]any{ "issuer": s.issuer, "jwks_uri": s.issuer + "/jwks", "authorization_endpoint": s.issuer + "/auth", "token_endpoint": s.issuer + "/token", "id_token_signing_alg_values_supported": []string{"RS256"}, "response_types_supported": []string{"id_token"}, "subject_types_supported": []string{"public"}, } _ = json.NewEncoder(w).Encode(body) } func (s *Stub) handleJWKS(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") jwk := map[string]any{ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": s.kid, "n": base64URLUint(s.key.N), "e": base64URLUintFromInt(s.key.E), } _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}}) } func (s *Stub) handleDevToken(w http.ResponseWriter, r *http.Request) { sub := r.URL.Query().Get("sub") if sub == "" { http.Error(w, `{"error":"sub is required"}`, http.StatusBadRequest) return } ttl := s.defaultTTL if expStr := r.URL.Query().Get("exp"); expStr != "" { d, err := time.ParseDuration(expStr) if err != nil { http.Error(w, `{"error":"invalid exp: `+expStr+`"}`, http.StatusBadRequest) return } ttl = d } tok, expiresAt, err := s.Mint(sub, ttl, nil) if err != nil { http.Error(w, `{"error":"mint failed"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "token": tok, "expires_at": expiresAt.Unix(), }) } // SignToken produces a signed RS256 JWT. The supplied claims are merged with // defaults {iss, iat, exp=iat+1h}; caller-supplied claims win on conflict. func (s *Stub) SignToken(claims map[string]any) (string, error) { now := time.Now() merged := map[string]any{ "iss": s.issuer, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } for k, v := range claims { merged[k] = v } return s.signRaw(merged) } // Mint is a convenience method that builds a full token for subject sub. // It sets: iss, aud (if s.audience is set), iat, exp=iat+ttl, // the configured UsernameClaim (if non-empty) to sub, and sub=sub. // Extra claims are merged last so callers can override any default. // Returns the signed token and its expiration time. func (s *Stub) Mint(sub string, ttl time.Duration, extra map[string]any) (string, time.Time, error) { now := time.Now() expiresAt := now.Add(ttl) claims := map[string]any{ "iss": s.issuer, "sub": sub, "iat": now.Unix(), "exp": expiresAt.Unix(), } if s.audience != "" { claims["aud"] = s.audience } if s.usernameClaim != "" { claims[s.usernameClaim] = sub } // extra overrides everything above. for k, v := range extra { claims[k] = v } tok, err := s.signRaw(claims) if err != nil { return "", time.Time{}, err } return tok, expiresAt, nil } // signRaw signs the supplied claims map exactly as-is (no merging). func (s *Stub) signRaw(claims map[string]any) (string, error) { header := map[string]any{ "alg": "RS256", "typ": "JWT", "kid": s.kid, } headerBytes, err := json.Marshal(header) if err != nil { return "", culpa.WithCode( culpa.Wrap(err, "oidcstub: marshal JWT header"), "OIDCSTUB_SIGN", ) } payloadBytes, err := json.Marshal(claims) if err != nil { return "", culpa.WithCode( culpa.Wrap(err, "oidcstub: marshal JWT payload"), "OIDCSTUB_SIGN", ) } signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(payloadBytes) digest := sha256.Sum256([]byte(signingInput)) sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, digest[:]) if err != nil { return "", culpa.WithCode( culpa.Wrap(err, "oidcstub: rsa.SignPKCS1v15"), "OIDCSTUB_SIGN", ) } return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil } // base64URLUint encodes a big.Int as base64url with no padding, per JWA // (RFC 7518) §6.3.1.1. func base64URLUint(n *big.Int) string { return base64.RawURLEncoding.EncodeToString(n.Bytes()) } // base64URLUintFromInt encodes the public exponent (typically 65537) as the // minimal big-endian byte representation, per JWA §6.3.1.2. func base64URLUintFromInt(e int) string { if e <= 0 { panic(fmt.Sprintf("oidcstub: invalid RSA exponent: %d", e)) } var buf [8]byte binary.BigEndian.PutUint64(buf[:], uint64(e)) // Strip leading zero bytes so the encoding is minimal. i := 0 for i < len(buf)-1 && buf[i] == 0 { i++ } return base64.RawURLEncoding.EncodeToString(buf[i:]) }