Files
summercms/modules/wristband/oidc_fakes_test.go
Jakub Zych dfa3c3e8b4 feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider
- Full validation chain with PHP order and messages; repeated params last-wins
- is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss
- One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode
  ConsentRequest and DecideConsent
- ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the
  other kind at authorize, token and userinfo (D-08)
- Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
2026-10-19 18:21:11 +02:00

584 lines
14 KiB
Go

package wristband
import (
"context"
"crypto"
"fmt"
"net/http"
"slices"
"sync"
"testing"
"time"
)
// memoryOIDCBackend is the in-memory OIDCBackend/OIDCTx for wristband's
// own tests. One mutex guards the whole transaction closure; it never rolls
// back, which the engine tolerates because it only returns an error from a
// transaction for server failures.
type memoryOIDCBackend struct {
mu sync.Mutex
clients []*OIDCClientRecord
requests []*AuthRequestRecord
codes []*OIDCCodeRecord
tokens []*OIDCAccessTokenRecord
refresh []*OIDCRefreshTokenRecord
consents []*ConsentRecord
subjects map[string]string // "<user>|<sector>" -> sub
identities map[uint]map[string]any
nextID uint
}
func newMemoryOIDCBackend() *memoryOIDCBackend {
return &memoryOIDCBackend{subjects: map[string]string{}, identities: map[uint]map[string]any{}}
}
func (b *memoryOIDCBackend) WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error {
b.mu.Lock()
defer b.mu.Unlock()
return fn(&memoryOIDCTx{b: b})
}
func (b *memoryOIDCBackend) id() uint {
b.nextID++
return b.nextID
}
// addClient registers an OIDC client whose secret is secret.
func (b *memoryOIDCBackend) addClient(c OIDCClientRecord, secret string) *OIDCClientRecord {
b.mu.Lock()
defer b.mu.Unlock()
if secret != "" {
h := sha256Hex(secret)
c.SecretHash = &h
}
c.ID = b.id()
cp := c
b.clients = append(b.clients, &cp)
return &cp
}
func (b *memoryOIDCBackend) setIdentity(userID uint, claims map[string]any) {
b.mu.Lock()
defer b.mu.Unlock()
b.identities[userID] = claims
}
func (b *memoryOIDCBackend) subject(userID uint, sector string) string {
b.mu.Lock()
defer b.mu.Unlock()
return b.subjects[fmt.Sprintf("%d|%s", userID, sector)]
}
func (b *memoryOIDCBackend) accessTokens() []OIDCAccessTokenRecord {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]OIDCAccessTokenRecord, len(b.tokens))
for i, t := range b.tokens {
out[i] = *t
}
return out
}
type memoryOIDCTx struct{ b *memoryOIDCBackend }
var _ OIDCTx = (*memoryOIDCTx)(nil)
func (t *memoryOIDCTx) OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error) {
for _, c := range t.b.clients {
if c.ClientID == clientID {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.requests = append(t.b.requests, &cp)
return nil
}
func (t *memoryOIDCTx) findRequest(requestID string) *AuthRequestRecord {
for _, r := range t.b.requests {
if r.RequestID == requestID {
return r
}
}
return nil
}
func (t *memoryOIDCTx) AuthRequestByRequestID(ctx context.Context, requestID string) (*AuthRequestRecord, error) {
if r := t.findRequest(requestID); r != nil {
cp := *r
return &cp, nil
}
return nil, nil
}
func (t *memoryOIDCTx) AuthRequestByRequestIDForUpdate(ctx context.Context, requestID string) (*AuthRequestRecord, error) {
return t.AuthRequestByRequestID(ctx, requestID)
}
func (t *memoryOIDCTx) ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error) {
for _, r := range t.b.requests {
if r.ID == id {
if r.ConsumedAt != nil || !r.ExpiresAt.After(now) {
return false, nil
}
n := now
r.ConsumedAt = &n
return true, nil
}
}
return false, nil
}
func (t *memoryOIDCTx) SetConsentTokenHash(ctx context.Context, id uint, hash *string) error {
for _, r := range t.b.requests {
if r.ID == id {
if hash == nil {
r.ConsentTokenHash = nil
} else {
h := *hash
r.ConsentTokenHash = &h
}
}
}
return nil
}
func (t *memoryOIDCTx) CreateCode(ctx context.Context, rec *OIDCCodeRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.codes = append(t.b.codes, &cp)
return nil
}
func (t *memoryOIDCTx) ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error) {
for _, c := range t.b.codes {
if c.CodeHash == codeHash {
if c.ConsumedAt != nil || !c.ExpiresAt.After(now) {
return nil, nil
}
n := now
c.ConsumedAt = &n
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CodeByHash(ctx context.Context, codeHash string) (*OIDCCodeRecord, error) {
for _, c := range t.b.codes {
if c.CodeHash == codeHash {
cp := *c
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) CreateAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.tokens = append(t.b.tokens, &cp)
return nil
}
func (t *memoryOIDCTx) AccessTokenByHash(ctx context.Context, tokenHash string) (*OIDCAccessTokenRecord, error) {
for _, tok := range t.b.tokens {
if tok.TokenHash == tokenHash {
cp := *tok
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) TouchAccessToken(ctx context.Context, id uint, now time.Time) error {
for _, tok := range t.b.tokens {
if tok.ID == id {
n := now
tok.LastUsedAt = &n
}
}
return nil
}
func (t *memoryOIDCTx) revokeAccessToken(id uint, now time.Time) {
for _, tok := range t.b.tokens {
if tok.ID == id && tok.RevokedAt == nil {
n := now
tok.RevokedAt = &n
}
}
}
func (t *memoryOIDCTx) RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error {
for _, tok := range t.b.tokens {
if tok.AuthCodeID != nil && *tok.AuthCodeID == codeID {
t.revokeAccessToken(tok.ID, now)
for _, rt := range t.b.refresh {
if rt.AccessTokenID != nil && *rt.AccessTokenID == tok.ID {
if err := t.RevokeRefreshLineage(ctx, rt.ID, now); err != nil {
return err
}
}
}
}
}
return nil
}
func (t *memoryOIDCTx) ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error) {
for _, c := range t.b.consents {
if c.UserID == userID && c.ClientID == clientID {
cp := *c
cp.Scopes = slices.Clone(c.Scopes)
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) SaveConsent(ctx context.Context, userID uint, clientID string, scopes []string, now time.Time) error {
n := now
for _, c := range t.b.consents {
if c.UserID == userID && c.ClientID == clientID {
for _, sc := range scopes {
if !slices.Contains(c.Scopes, sc) {
c.Scopes = append(c.Scopes, sc)
}
}
c.LastUsedAt = &n
return nil
}
}
t.b.consents = append(t.b.consents, &ConsentRecord{
ID: t.b.id(), UserID: userID, ClientID: clientID,
Scopes: slices.Clone(scopes), CreatedAt: now, LastUsedAt: &n,
})
return nil
}
func (t *memoryOIDCTx) TouchConsent(ctx context.Context, id uint, now time.Time) error {
for _, c := range t.b.consents {
if c.ID == id {
n := now
c.LastUsedAt = &n
}
}
return nil
}
func (t *memoryOIDCTx) SubjectFor(ctx context.Context, userID uint, sector, candidate string) (string, error) {
key := fmt.Sprintf("%d|%s", userID, sector)
if sub, ok := t.b.subjects[key]; ok {
return sub, nil
}
t.b.subjects[key] = candidate
return candidate, nil
}
func (t *memoryOIDCTx) IdentityClaims(ctx context.Context, userID uint, scopes []string) (map[string]any, bool, error) {
all, ok := t.b.identities[userID]
if !ok {
return nil, false, nil
}
out := map[string]any{}
if slices.Contains(scopes, "profile") {
for _, k := range []string{"name", "picture", "groups", "communities"} {
if v, ok := all[k]; ok {
out[k] = v
}
}
}
if slices.Contains(scopes, "email") {
for _, k := range []string{"email", "email_verified"} {
if v, ok := all[k]; ok {
out[k] = v
}
}
}
// Extra keys let tests prove the allowlist and protocol-claim guard.
for k, v := range all {
if k == "iss" || k == "aud" || k == "x_extra" {
out[k] = v
}
}
return out, true, nil
}
func (t *memoryOIDCTx) CreateRefreshToken(ctx context.Context, rec *OIDCRefreshTokenRecord) error {
rec.ID = t.b.id()
cp := *rec
t.b.refresh = append(t.b.refresh, &cp)
return nil
}
func (t *memoryOIDCTx) RefreshTokenByHashForUpdate(ctx context.Context, tokenHash string) (*OIDCRefreshTokenRecord, error) {
for _, rt := range t.b.refresh {
if rt.TokenHash == tokenHash {
cp := *rt
return &cp, nil
}
}
return nil, nil
}
func (t *memoryOIDCTx) MarkRefreshRotated(ctx context.Context, id, successorID uint) error {
for _, rt := range t.b.refresh {
if rt.ID == id {
s := successorID
rt.RotatedToID = &s
}
}
return nil
}
func (t *memoryOIDCTx) RevokeRefreshLineage(ctx context.Context, startID uint, now time.Time) error {
next := &startID
for next != nil {
var cur *OIDCRefreshTokenRecord
for _, rt := range t.b.refresh {
if rt.ID == *next {
cur = rt
}
}
if cur == nil {
return nil
}
if cur.RevokedAt == nil {
n := now
cur.RevokedAt = &n
}
if cur.AccessTokenID != nil {
t.revokeAccessToken(*cur.AccessTokenID, now)
}
next = cur.RotatedToID
}
return nil
}
// memoryKeyStore is an in-memory KeyStore built from GenerateSigningKey.
type memoryKeyStore struct {
mu sync.Mutex
keys []*memoryKey
grace time.Duration
// alg is the algorithm of a lazily generated first key.
alg string
}
type memoryKey struct {
kid string
alg string
signer crypto.Signer
public crypto.PublicKey
active bool
retiredAt *time.Time
}
func newMemoryKeyStore() *memoryKeyStore {
return &memoryKeyStore{grace: 24 * time.Hour, alg: AlgRS256}
}
// rotate generates a new active key for alg and retires the old one at
// retiredAt.
func (k *memoryKeyStore) rotate(t *testing.T, alg string, retiredAt time.Time) string {
t.Helper()
k.mu.Lock()
defer k.mu.Unlock()
key, err := generateMemoryKey(alg)
if err != nil {
t.Fatal(err)
}
for _, old := range k.keys {
if old.active {
old.active = false
r := retiredAt
old.retiredAt = &r
}
}
k.keys = append(k.keys, key)
return key.kid
}
func generateMemoryKey(alg string) (*memoryKey, error) {
kid, privPEM, pubPEM, err := GenerateSigningKey(alg)
if err != nil {
return nil, err
}
signer, err := ParsePrivateKeyPEM(privPEM)
if err != nil {
return nil, err
}
pub, err := ParsePublicKeyPEM(pubPEM)
if err != nil {
return nil, err
}
return &memoryKey{kid: kid, alg: alg, signer: signer, public: pub, active: true}, nil
}
func (k *memoryKeyStore) ActiveSigningKey(ctx context.Context) (SigningKey, error) {
k.mu.Lock()
defer k.mu.Unlock()
for _, key := range k.keys {
if key.active {
return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil
}
}
key, err := generateMemoryKey(k.alg)
if err != nil {
return SigningKey{}, err
}
k.keys = append(k.keys, key)
return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil
}
func (k *memoryKeyStore) PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []PublicKey
for _, key := range k.keys {
if key.active || (key.retiredAt != nil && now.Before(key.retiredAt.Add(k.grace))) {
out = append(out, PublicKey{Kid: key.kid, Alg: key.alg, Key: key.public})
}
}
return out, nil
}
// fakeOIDCHost is a settable OIDCHost that records what the engine asked
// it to render.
type fakeOIDCHost struct {
mu sync.Mutex
userID uint
signedIn bool
views []ConsentView
errors []string
endSessions int
}
func (h *fakeOIDCHost) signIn(userID uint) {
h.mu.Lock()
defer h.mu.Unlock()
h.userID, h.signedIn = userID, true
}
func (h *fakeOIDCHost) signOut() {
h.mu.Lock()
defer h.mu.Unlock()
h.signedIn = false
}
func (h *fakeOIDCHost) SignedInUser(r *http.Request) (uint, bool, error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.userID, h.signedIn, nil
}
func (h *fakeOIDCHost) RenderAuthorizeError(w http.ResponseWriter, r *http.Request, code string) {
h.mu.Lock()
h.errors = append(h.errors, code)
h.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(code))
}
func (h *fakeOIDCHost) RenderConsent(w http.ResponseWriter, r *http.Request, view ConsentView) {
h.mu.Lock()
h.views = append(h.views, view)
h.mu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("consent"))
}
func (h *fakeOIDCHost) EndSession(w http.ResponseWriter, r *http.Request) error {
h.mu.Lock()
defer h.mu.Unlock()
h.endSessions++
h.signedIn = false
return nil
}
func (h *fakeOIDCHost) lastView(t *testing.T) ConsentView {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if len(h.views) == 0 {
t.Fatal("no consent view was rendered")
}
return h.views[len(h.views)-1]
}
// oidcFixture is an OIDC server over the memory fakes plus one active,
// confidential, public-subject client.
type oidcFixture struct {
srv *Server
backend *memoryOIDCBackend
keys *memoryKeyStore
host *fakeOIDCHost
client *OIDCClientRecord
clientSecret string
}
const (
testOIDCIssuer = "https://issuer.example.test"
testOIDCRedirect = "https://rp.example.test/callback"
testOIDCSecret = "oas_cs_test-secret-0123456789abcdefghijklmnopqrstuvwx"
)
func newOIDCFixture(t *testing.T, issuer string, mutate func(*OIDCOptions)) *oidcFixture {
t.Helper()
oidc := DefaultOIDCOptions()
if mutate != nil {
mutate(&oidc)
}
opts := DefaultOptions()
opts.Issuer = issuer
srv := NewOIDCServer(opts, oidc)
f := &oidcFixture{
srv: srv,
backend: newMemoryOIDCBackend(),
keys: newMemoryKeyStore(),
host: &fakeOIDCHost{},
clientSecret: testOIDCSecret,
}
if err := srv.SetOIDCBackend(f.backend, f.keys, f.host); err != nil {
t.Fatal(err)
}
f.client = f.backend.addClient(OIDCClientRecord{
ClientID: "oas_0123456789abcdef01234567",
Name: "Example RP",
Kind: ClientKindOIDC,
RedirectURIs: []string{testOIDCRedirect},
Scopes: []string{"openid", "profile", "email"},
IsActive: true,
SubjectType: SubjectTypePublic,
}, testOIDCSecret)
f.backend.setIdentity(7, map[string]any{
"name": "Ada Example", "email": "ada@example.test", "email_verified": true,
"groups": []string{"editors"}, "communities": []any{},
"iss": "https://evil.example.test", "x_extra": "dropped",
})
return f
}
// mux mounts the OIDC routes the way a host application would.
func (f *oidcFixture) mux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", f.srv.Discovery)
mux.HandleFunc("GET /oauth/jwks", f.srv.JWKS)
mux.HandleFunc("GET /oauth/authorize", f.srv.Authorize)
mux.HandleFunc("POST /oauth/authorize", f.srv.Authorize)
mux.HandleFunc("GET /oauth/authorize/resume/{request}", f.srv.Resume)
mux.HandleFunc("POST /oauth/authorize/switch-account", f.srv.SwitchAccount)
mux.HandleFunc("POST /oauth/token", f.srv.Token)
mux.HandleFunc("GET /oauth/userinfo", f.srv.UserInfo)
return mux
}