2026-09-05 01:08:58 +02:00
|
|
|
package authclient
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-07 09:02:05 +02:00
|
|
|
"crypto"
|
2026-09-05 01:08:58 +02:00
|
|
|
"crypto/rand"
|
|
|
|
|
"crypto/rsa"
|
2026-09-07 09:02:05 +02:00
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/base64"
|
2026-09-05 01:08:58 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"keycape/internal/domain"
|
|
|
|
|
"keycape/internal/server/oidc"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type users struct{}
|
|
|
|
|
|
|
|
|
|
func (users) LookupUser(context.Context, string) (*domain.User, error) {
|
|
|
|
|
return &domain.User{ID: "user:test", Username: "test"}, nil
|
|
|
|
|
}
|
|
|
|
|
func (users) LookupGroups(context.Context, string) ([]domain.Group, error) { return nil, nil }
|
|
|
|
|
func (users) ValidatePassword(context.Context, string, string) (bool, error) { return true, nil }
|
|
|
|
|
func (users) ListUsers(context.Context) ([]domain.User, error) { return nil, nil }
|
|
|
|
|
|
|
|
|
|
func provider(t *testing.T) (*Client, Discovery, *oidc.TokenHandler) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
server := httptest.NewTLSServer(mux)
|
|
|
|
|
t.Cleanup(server.Close)
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
h := &oidc.TokenHandler{Issuer: server.URL, SigningKey: key, TokenLifetime: 15 * time.Minute, Sessions: sessions, Users: users{}, Emitter: telemetry.NoopEmitter{}, ClientConfig: map[string]*domain.Client{
|
|
|
|
|
"service:consumer": {ClientID: "service:consumer", ClientType: "confidential", ClientSecret: "special+%: secret", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"approval:read"}, Audience: "approval-engine", ServiceSubject: "service:test", Tenant: "tenant:test"},
|
|
|
|
|
"human": {ClientID: "human", AllowedScopes: []string{"openid", "approval:approve"}, Audience: "approval-engine"},
|
|
|
|
|
}}
|
|
|
|
|
mux.Handle("/token", h)
|
|
|
|
|
keys := oidc.NewKeySet()
|
|
|
|
|
keys.AddKey("key-1", &key.PublicKey)
|
|
|
|
|
mux.Handle("/jwks", oidc.NewJWKSHandler(keys))
|
|
|
|
|
d := Discovery{Issuer: server.URL, Authorization: server.URL + "/authorize", Token: server.URL + "/token", JWKS: server.URL + "/jwks"}
|
|
|
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(d) })
|
|
|
|
|
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
q := r.URL.Query()
|
Harden the authorization-code grant and UserInfo verification
Closes the local protocol surface of gap G01 from the scope assessment
(KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left
four bindings unenforced, and UserInfo verified less than the caller CLI does.
Authorization-code path: bind the exchange to the redirect URI the code was
issued for, refuse clients whose registration does not permit the grant, and
authenticate confidential clients with a digest-based constant-time comparison
over the same credential sources as the service grant. An empty grantTypes stays
an implicit authorization-code client, matching config validation.
Code consumption: SessionStore.Consume reads and deletes under one lock. The
previous Get/Delete pair spanned JWT signing, and the added test reproduces the
race against that version -- 9 of 16 concurrent exchanges succeeded, and a
failed exchange left the code replayable.
UserInfo: check the JOSE header algorithm before trusting the signature, require
the configured issuer, and require an access token rather than accepting an ID
token of the right shape. Purpose is decided on the scope claim so the issued
token contract, which consumers pin exactly, does not change.
SCOPE.md and the assessment record which bindings are now enforced and that the
Authelia upstream-trust assumption remains open, so G01 is not fully closed and
no profile-conformance claim is made.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:43:47 +02:00
|
|
|
// Record the redirect URI as the real /authorize does: the token
|
|
|
|
|
// endpoint binds the exchange to it (KEY-WP-0016-T02).
|
|
|
|
|
code := sessions.Create(&oidc.PKCESession{ClientID: q.Get("client_id"), Username: "test", Nonce: q.Get("nonce"), Scopes: strings.Fields(q.Get("scope")), PKCEChallenge: q.Get("code_challenge"), RedirectURI: q.Get("redirect_uri"), ExpiresAt: time.Now().Add(time.Minute)})
|
2026-09-05 01:08:58 +02:00
|
|
|
target, _ := url.Parse(q.Get("redirect_uri"))
|
|
|
|
|
params := target.Query()
|
|
|
|
|
params.Set("state", q.Get("state"))
|
|
|
|
|
params.Set("code", code)
|
|
|
|
|
target.RawQuery = params.Encode()
|
|
|
|
|
http.Redirect(w, r, target.String(), 302)
|
|
|
|
|
})
|
|
|
|
|
c, err := New(server.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
c.HTTP.Transport = server.Client().Transport
|
|
|
|
|
return c, d, h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestServiceExchangeAndClaimValidation(t *testing.T) {
|
|
|
|
|
c, _, h := provider(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
d, err := c.Discover(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
|
|
|
|
|
token, err := c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err = c.Verify(ctx, d, token.AccessToken, "other", ""); err == nil {
|
|
|
|
|
t.Fatal("wrong audience accepted")
|
|
|
|
|
}
|
|
|
|
|
if _, err = c.Verify(ctx, d, token.AccessToken+"tampered", "approval-engine", ""); err == nil {
|
|
|
|
|
t.Fatal("tampering accepted")
|
|
|
|
|
}
|
|
|
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "wrong", "approval-engine", ""); err == nil || strings.Contains(err.Error(), "special") {
|
|
|
|
|
t.Fatal("wrong secret not safely rejected")
|
|
|
|
|
}
|
|
|
|
|
form.Set("scope", "approval:consume")
|
|
|
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
|
|
|
|
|
t.Fatal("excess scope accepted")
|
|
|
|
|
}
|
|
|
|
|
form.Set("scope", "approval:read")
|
|
|
|
|
h.TokenLifetime = -time.Minute
|
|
|
|
|
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
|
|
|
|
|
t.Fatal("expired response accepted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type urlWriter struct{ urls chan string }
|
|
|
|
|
|
|
|
|
|
func (w urlWriter) Write(p []byte) (int, error) {
|
|
|
|
|
for _, line := range strings.Split(string(p), "\n") {
|
|
|
|
|
if strings.HasPrefix(line, "https://") {
|
|
|
|
|
w.urls <- line
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return len(p), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBrowserLoginPKCEAndState(t *testing.T) {
|
|
|
|
|
c, d, _ := provider(t)
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
redirect := "http://" + listener.Addr().String() + "/callback"
|
|
|
|
|
listener.Close()
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
urls := make(chan string, 1)
|
|
|
|
|
completed := make(chan error, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
_, err := c.Login(ctx, d, "human", "approval-engine", "openid approval:approve", redirect, urlWriter{urls})
|
|
|
|
|
completed <- err
|
|
|
|
|
}()
|
|
|
|
|
var address string
|
|
|
|
|
select {
|
|
|
|
|
case address = <-urls:
|
|
|
|
|
case err := <-completed:
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
t.Fatal("no login URL")
|
|
|
|
|
}
|
|
|
|
|
// A forged callback must not consume the real login attempt.
|
|
|
|
|
res, err := http.Get(redirect + "?state=forged&code=forged")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
res.Body.Close()
|
|
|
|
|
if res.StatusCode != 400 {
|
|
|
|
|
t.Fatal("forged state accepted")
|
|
|
|
|
}
|
|
|
|
|
browser := &http.Client{Transport: c.HTTP.Transport, Timeout: 5 * time.Second}
|
|
|
|
|
res, err = browser.Get(address)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
io.Copy(io.Discard, res.Body)
|
|
|
|
|
res.Body.Close()
|
|
|
|
|
select {
|
|
|
|
|
case err := <-completed:
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
t.Fatal("login did not finish")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLoginRejectsUnsafeCallbacks(t *testing.T) {
|
|
|
|
|
c, d, _ := provider(t)
|
|
|
|
|
for _, callback := range []string{"http://example.com:8000/callback", "http://localhost:8000/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:8000/callback?extra=yes"} {
|
|
|
|
|
if _, err := c.Login(context.Background(), d, "human", "approval-engine", "openid", callback, io.Discard); err == nil {
|
|
|
|
|
t.Fatalf("accepted %s", callback)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOutputProtection(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
path := filepath.Join(dir, "token.json")
|
|
|
|
|
file, err := reserveOutput(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("reserve %s: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
file.Close()
|
|
|
|
|
info, _ := os.Stat(path)
|
|
|
|
|
if info.Mode().Perm() != 0600 {
|
|
|
|
|
t.Fatal("file not private")
|
|
|
|
|
}
|
|
|
|
|
if _, err = reserveOutput(path); err == nil {
|
|
|
|
|
t.Fatal("overwrote existing file")
|
|
|
|
|
}
|
|
|
|
|
link := filepath.Join(dir, "link")
|
|
|
|
|
if err = os.Symlink(path, link); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err = reserveOutput(link); err == nil {
|
|
|
|
|
t.Fatal("followed output symlink")
|
|
|
|
|
}
|
|
|
|
|
repo := filepath.Join(dir, "repo")
|
|
|
|
|
os.Mkdir(repo, 0700)
|
|
|
|
|
os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: elsewhere"), 0600)
|
|
|
|
|
if _, err = reserveOutput(filepath.Join(repo, "token")); err == nil {
|
|
|
|
|
t.Fatal("allowed token in worktree")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDiscoveryAndRedirectBoundaries(t *testing.T) {
|
|
|
|
|
for _, issuer := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?secret=value"} {
|
|
|
|
|
if _, err := New(issuer); err == nil {
|
|
|
|
|
t.Fatal("unsafe issuer accepted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
server := httptest.NewTLSServer(mux)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
c, _ := New(server.URL)
|
|
|
|
|
c.HTTP.Transport = server.Client().Transport
|
|
|
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":"https://evil.example/a","token_endpoint":"https://evil.example/t","jwks_uri":"https://evil.example/j"}`, server.URL)
|
|
|
|
|
})
|
|
|
|
|
if _, err := c.Discover(context.Background()); err == nil {
|
|
|
|
|
t.Fatal("cross-origin discovery accepted")
|
|
|
|
|
}
|
|
|
|
|
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://evil.example", 307) })
|
|
|
|
|
var out any
|
|
|
|
|
if err := c.request(context.Background(), "POST", server.URL+"/redirect", nil, "id", "secret", &out); err == nil {
|
|
|
|
|
t.Fatal("followed credential redirect")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNonceMismatchAndCancelledLogin(t *testing.T) {
|
|
|
|
|
c, d, _ := provider(t)
|
|
|
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
|
|
|
|
|
token, err := c.Exchange(context.Background(), d, form, "service:consumer", "special+%: secret", "approval-engine", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if _, err = c.Verify(context.Background(), d, token.AccessToken, "approval-engine", "required-nonce"); err == nil {
|
|
|
|
|
t.Fatal("missing nonce accepted")
|
|
|
|
|
}
|
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
redirect := "http://" + listener.Addr().String() + "/callback"
|
|
|
|
|
listener.Close()
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
cancel()
|
|
|
|
|
if _, err = c.Login(ctx, d, "human", "approval-engine", "openid", redirect, io.Discard); err == nil {
|
|
|
|
|
t.Fatal("cancelled login succeeded")
|
|
|
|
|
}
|
|
|
|
|
listener, err = net.Listen("tcp", strings.TrimPrefix(strings.TrimSuffix(redirect, "/callback"), "http://"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal("listener not released")
|
|
|
|
|
}
|
|
|
|
|
listener.Close()
|
|
|
|
|
}
|
2026-09-07 09:02:05 +02:00
|
|
|
|
|
|
|
|
// The caller-side verifier moved onto internal/jose (KEY-WP-0019-T05). The
|
2026-09-07 09:05:16 +02:00
|
|
|
// existing tamper case proves the signature is checked at all; these two prove
|
|
|
|
|
// the parts of the move that a byte-level tamper cannot reach. A token signed
|
|
|
|
|
// by an unpublished key under a kid the provider does publish fails only if key
|
|
|
|
|
// selection is bound to the published set, not merely if the bytes are intact.
|
|
|
|
|
// A key set carrying an undersized modulus must deny the login rather than fall
|
|
|
|
|
// through to the claims, which is ParseJWKS strictness being applied on this
|
|
|
|
|
// path and not only in the adapter.
|
2026-09-07 09:02:05 +02:00
|
|
|
func TestVerifyRejectsForeignSignatureAndUnusableKeySet(t *testing.T) {
|
|
|
|
|
c, d, _ := provider(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
foreign, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
now := time.Now()
|
|
|
|
|
forged := signToken(t, foreign, "key-1", map[string]any{
|
|
|
|
|
"iss": c.Issuer,
|
|
|
|
|
"aud": "approval-engine",
|
|
|
|
|
"sub": "service:test",
|
|
|
|
|
"iat": now.Unix(),
|
|
|
|
|
"exp": now.Add(time.Minute).Unix(),
|
|
|
|
|
})
|
|
|
|
|
if _, err := c.Verify(ctx, d, forged, "approval-engine", ""); err == nil {
|
|
|
|
|
t.Fatal("token signed by an unpublished key accepted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A key set the client cannot use must deny, not fall through to the
|
|
|
|
|
// claims. The token here is otherwise genuine.
|
|
|
|
|
genuine := signToken(t, foreign, "key-1", map[string]any{
|
|
|
|
|
"iss": c.Issuer, "aud": "approval-engine", "sub": "service:test",
|
|
|
|
|
"iat": now.Unix(), "exp": now.Add(time.Minute).Unix(),
|
|
|
|
|
})
|
|
|
|
|
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
fmt.Fprint(w, `{"keys":[{"kty":"RSA","kid":"key-1","n":"AQAB","e":"AQAB"}]}`)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(broken.Close)
|
|
|
|
|
if _, err := c.Verify(ctx, Discovery{Issuer: d.Issuer, JWKS: broken.URL}, genuine, "approval-engine", ""); err == nil {
|
|
|
|
|
t.Fatal("undersized key in the published set accepted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// signToken builds an RS256 JWT with the given kid and claims.
|
|
|
|
|
func signToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
enc := func(v any) string {
|
|
|
|
|
raw, err := json.Marshal(v)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
|
|
|
}
|
|
|
|
|
input := enc(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid}) + "." + enc(claims)
|
|
|
|
|
digest := sha256.Sum256([]byte(input))
|
|
|
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return input + "." + base64.RawURLEncoding.EncodeToString(signature)
|
|
|
|
|
}
|