Share current-token verification and scoped cleanup through one producer client
Some checks failed
Authentication acceptance / acceptance (push) Failing after 59s
Authentication acceptance / provider-contract (push) Successful in 13s
Build and Publish Container Image / build-and-push (push) Successful in 31s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
tegwick 2026-10-18 11:36:26 +02:00
parent 67a0869c1e
commit 08c083bae9
19 changed files with 2228 additions and 3 deletions

View file

@ -49,7 +49,7 @@ func main() {
return
}
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token" || os.Args[1] == "verify-client") {
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token" || os.Args[1] == "verify-client" || os.Args[1] == "check-current-token" || os.Args[1] == "revoke-token" || os.Args[1] == "check-inactive-token") {
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)

View file

@ -25,6 +25,9 @@ func Run(ctx context.Context, args []string, stderr io.Writer) error {
return errors.New("expected login, service-token or verify-client")
}
mode := args[0]
if mode == "check-current-token" || mode == "revoke-token" || mode == "check-inactive-token" {
return runOnline(ctx, mode, args[1:], stderr)
}
if mode == "verify-client" {
return verifyClient(ctx, args[1:], stderr)
}

View file

@ -0,0 +1,379 @@
package authclient
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math"
"mime"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"keycape/internal/jose"
)
var ErrOnlineToken = errors.New("current authenticated token refused")
// OnlineAdmission is independently admitted owner configuration. The public
// JWKS/CA and private client credential files are reread for every request.
// It selects the owning confidential client, not a resource-server delegation.
type OnlineAdmission struct {
Issuer string `json:"issuer"`
Audience string `json:"audience"`
ClientID string `json:"client_id"`
CAFile string `json:"ca_file"`
JWKSFile string `json:"jwks_file"`
SecretFile string `json:"secret_file"`
MaxTokenAge string `json:"max_token_age"`
}
type ExpectedIdentity struct {
Subject string
Tenant string
PrincipalType string
}
// CurrentToken contains signed identity facts joined to a current issuer
// observation. It is never a permission, approval or portable execution receipt.
type CurrentToken struct {
Issuer string `json:"issuer"`
Audience string `json:"audience"`
ClientID string `json:"client_id"`
Subject string `json:"subject"`
Tenant string `json:"tenant"`
PrincipalType string `json:"principal_type"`
TokenID string `json:"jti"`
TokenDigest string `json:"token_sha256"`
IssuedAt time.Time `json:"issued_at"`
NotAfter time.Time `json:"not_after"`
CheckedAt time.Time `json:"checked_at"`
claims map[string]interface{}
}
// Claims returns a separate copy of the verified signed facts. Authentication
// assurance/age, tenant provenance, current groups and resource/effect policy
// remain the consumer's existing profile obligations, not a new client policy.
func (t CurrentToken) Claims() map[string]interface{} {
if t.claims == nil {
return nil
}
raw, _ := json.Marshal(t.claims)
var out map[string]interface{}
_ = json.Unmarshal(raw, &out)
return out
}
// InactiveForClient proves only the issuer's current negative response for the
// named client/token digest. An unknown/other-client token gives the same result;
// original grant ownership is separately required before asserting cleanup.
type InactiveForClient struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
TokenDigest string `json:"token_sha256"`
CheckedAt time.Time `json:"checked_at"`
}
type OnlineClient struct {
admission OnlineAdmission
age time.Duration
now func() time.Time
// Nil uses the normal resolver/dialer. Tests route a certified DNS name to
// isolated loopback; the TLS name/CA and JWT trust checks remain unchanged.
dialContext func(context.Context, string, string) (net.Conn, error)
}
func NewOnlineClient(a OnlineAdmission) (*OnlineClient, error) {
u, err := url.Parse(a.Issuer)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || strings.ContainsAny(a.Issuer, "?#") || u.RawPath != "" || (u.Path != "" && path.Clean(u.Path) != u.Path) || strings.HasSuffix(a.Issuer, "/") {
return nil, ErrOnlineToken
}
if a.Audience == "" || a.ClientID == "" || !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.JWKSFile) || !filepath.IsAbs(a.SecretFile) {
return nil, ErrOnlineToken
}
age, err := time.ParseDuration(a.MaxTokenAge)
if err != nil || age <= 0 || age > time.Hour {
return nil, ErrOnlineToken
}
return &OnlineClient{admission: a, age: age, now: time.Now}, nil
}
func readOnlineFile(path string, private bool) ([]byte, error) {
limit := int64(1 << 20)
if private {
limit = 16384
}
return readOnlineBoundedFile(path, private, limit)
}
func readOnlineBoundedFile(path string, private bool, limit int64) ([]byte, error) {
if limit <= 0 || limit > 1<<20 {
return nil, ErrOnlineToken
}
if !filepath.IsAbs(path) {
return nil, ErrOnlineToken
}
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, ErrOnlineToken
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 || (private && info.Mode().Perm()&0007 != 0) {
return nil, ErrOnlineToken
}
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil || len(raw) > int(limit) || len(raw) == 0 {
return nil, ErrOnlineToken
}
return raw, nil
}
func tokenDigest(raw string) string {
digest := sha256.Sum256([]byte(raw))
return "sha256:" + hex.EncodeToString(digest[:])
}
func exactUnix(value interface{}) (time.Time, bool) {
n, ok := value.(float64)
if !ok || n <= 0 || n > 9007199254740991 || math.Trunc(n) != n {
return time.Time{}, false
}
return time.Unix(int64(n), 0).UTC(), true
}
func (c *OnlineClient) verify(raw string, expected ExpectedIdentity, now time.Time) (CurrentToken, error) {
var out CurrentToken
if c == nil || len(raw) == 0 || len(raw) > 32768 || expected.Subject == "" || expected.Tenant == "" || (expected.PrincipalType != "human" && expected.PrincipalType != "service") {
return out, ErrOnlineToken
}
keysRaw, err := readOnlineFile(c.admission.JWKSFile, false)
if err != nil {
return out, ErrOnlineToken
}
keys, err := jose.ParseJWKS(keysRaw)
if err != nil {
return out, ErrOnlineToken
}
claims, err := jose.VerifyTyped(raw, keys, "JWT")
if err != nil {
return out, ErrOnlineToken
}
if claims["iss"] != c.admission.Issuer || claims["aud"] != c.admission.Audience || claims["sub"] != expected.Subject || claims["tenant"] != expected.Tenant || claims["principal_type"] != expected.PrincipalType {
return out, ErrOnlineToken
}
if azp, present := claims["azp"]; present && azp != c.admission.ClientID {
return out, ErrOnlineToken
}
_, scopeOK := claims["scope"].(string)
jti, jtiOK := claims["jti"].(string)
decodedID, idErr := base64.RawURLEncoding.Strict().DecodeString(jti)
issued, issuedOK := exactUnix(claims["iat"])
expires, expiresOK := exactUnix(claims["exp"])
if !scopeOK || !jtiOK || idErr != nil || len(decodedID) != 32 || !issuedOK || !expiresOK || issued.After(now) || !expires.After(issued) || !now.Before(expires) {
return out, ErrOnlineToken
}
if nbf, present := claims["nbf"]; present {
start, valid := exactUnix(nbf)
if !valid || start.After(now) {
return out, ErrOnlineToken
}
}
end := issued.Add(c.age)
if expires.Before(end) {
end = expires
}
if !now.Before(end) {
return out, ErrOnlineToken
}
return CurrentToken{Issuer: c.admission.Issuer, Audience: c.admission.Audience, ClientID: c.admission.ClientID,
Subject: expected.Subject, Tenant: expected.Tenant, PrincipalType: expected.PrincipalType,
TokenID: jti, TokenDigest: tokenDigest(raw), IssuedAt: issued, NotAfter: end, CheckedAt: now, claims: claims}, nil
}
type statusWire struct {
Active *bool `json:"active"`
ClientID string `json:"client_id"`
TokenType string `json:"token_type"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scope string `json:"scope"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
}
func decodeOnlineObject(raw []byte, out interface{}) (map[string]bool, error) {
d := json.NewDecoder(bytes.NewReader(raw))
first, err := d.Token()
if err != nil || first != json.Delim('{') {
return nil, ErrOnlineToken
}
seen := map[string]bool{}
for d.More() {
key, err := d.Token()
name, ok := key.(string)
if err != nil || !ok || seen[name] {
return nil, ErrOnlineToken
}
seen[name] = true
var value json.RawMessage
if d.Decode(&value) != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return nil, ErrOnlineToken
}
}
if last, err := d.Token(); err != nil || last != json.Delim('}') {
return nil, ErrOnlineToken
}
if _, err := d.Token(); err != io.EOF {
return nil, ErrOnlineToken
}
d = json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(out) != nil {
return nil, ErrOnlineToken
}
return seen, nil
}
func decodeOnlineStatus(raw []byte) (statusWire, error) {
var out statusWire
seen, err := decodeOnlineObject(raw, &out)
if err != nil || out.Active == nil || (!*out.Active && len(seen) != 1) {
return out, ErrOnlineToken
}
if *out.Active {
if len(seen) != 10 {
return out, ErrOnlineToken
}
for _, field := range []string{"active", "client_id", "token_type", "iss", "sub", "aud", "scope", "iat", "exp", "jti"} {
if !seen[field] {
return out, ErrOnlineToken
}
}
}
return out, nil
}
func (c *OnlineClient) request(ctx context.Context, endpoint, raw string) (statusWire, error) {
var out statusWire
if c == nil || ctx == nil || ctx.Err() != nil || len(raw) == 0 || len(raw) > 32768 || (endpoint != "/introspect" && endpoint != "/revoke") {
return out, ErrOnlineToken
}
ca, err := readOnlineFile(c.admission.CAFile, false)
if err != nil {
return out, ErrOnlineToken
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(ca) {
return out, ErrOnlineToken
}
secretRaw, err := readOnlineFile(c.admission.SecretFile, true)
if err != nil {
return out, ErrOnlineToken
}
secret := strings.TrimSuffix(strings.TrimSuffix(string(secretRaw), "\n"), "\r")
if secret == "" || strings.ContainsAny(secret, "\r\n\x00") {
return out, ErrOnlineToken
}
form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Issuer+endpoint, strings.NewReader(form.Encode()))
if err != nil {
return out, ErrOnlineToken
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cache-Control", "no-cache")
req.SetBasicAuth(url.QueryEscape(c.admission.ClientID), url.QueryEscape(secret))
transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true, DialContext: c.dialContext}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrOnlineToken }}
response, err := client.Do(req)
if err != nil {
return out, ErrOnlineToken
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || response.Header.Get("Cache-Control") != "no-store" {
return out, ErrOnlineToken
}
body, err := io.ReadAll(io.LimitReader(response.Body, 65537))
if err != nil || len(body) > 65536 {
return out, ErrOnlineToken
}
if endpoint == "/revoke" {
if len(body) != 0 {
return out, ErrOnlineToken
}
return out, nil
}
media, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || media != "application/json" {
return out, ErrOnlineToken
}
return decodeOnlineStatus(body)
}
// Check verifies expected identity before accessing the private client credential,
// sends a new authenticated introspection and rechecks lifetime after the network.
func (c *OnlineClient) Check(ctx context.Context, raw string, expected ExpectedIdentity) (CurrentToken, error) {
if c == nil || ctx == nil || ctx.Err() != nil {
return CurrentToken{}, ErrOnlineToken
}
identity, err := c.verify(raw, expected, c.now().UTC())
if err != nil {
return CurrentToken{}, ErrOnlineToken
}
status, err := c.request(ctx, "/introspect", raw)
if err != nil || status.Active == nil || !*status.Active {
return CurrentToken{}, ErrOnlineToken
}
expires, _ := exactUnix(identity.claims["exp"])
if status.ClientID != c.admission.ClientID || status.TokenType != "Bearer" || status.Issuer != identity.Issuer || status.Subject != identity.Subject || status.Audience != identity.Audience || status.TokenID != identity.TokenID || status.Scope != identity.claims["scope"] || status.IssuedAt != identity.IssuedAt.Unix() || status.ExpiresAt != expires.Unix() {
return CurrentToken{}, ErrOnlineToken
}
end := c.now().UTC()
if end.Before(identity.CheckedAt) {
return CurrentToken{}, ErrOnlineToken
}
// A slow lookup cannot retain expired facts or a withdrawn JWT trust key.
return c.verify(raw, expected, end)
}
// Revoke performs cleanup only for the exact opaque token. An empty 200 is not
// proof: a separate authenticated introspection must confirm inactive-for-client.
// The caller retains original grant ownership and uncertainty/retry duties.
func (c *OnlineClient) Revoke(ctx context.Context, raw string) (InactiveForClient, error) {
if c == nil {
return InactiveForClient{}, ErrOnlineToken
}
if _, err := c.request(ctx, "/revoke", raw); err != nil {
return InactiveForClient{}, ErrOnlineToken
}
return c.ObserveInactive(ctx, raw)
}
// ObserveInactive is read-only. Unavailability or a malformed response is never
// interpreted as revocation; the negative result remains scoped to this client.
func (c *OnlineClient) ObserveInactive(ctx context.Context, raw string) (InactiveForClient, error) {
if c == nil {
return InactiveForClient{}, ErrOnlineToken
}
status, err := c.request(ctx, "/introspect", raw)
if err != nil || status.Active == nil || *status.Active {
return InactiveForClient{}, ErrOnlineToken
}
return InactiveForClient{Issuer: c.admission.Issuer, ClientID: c.admission.ClientID, TokenDigest: tokenDigest(raw), CheckedAt: c.now().UTC()}, nil
}

View file

@ -0,0 +1,160 @@
package authclient
import (
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// onlineObservation is a private producer-owned command result, not a signed
// authority receipt. The invoking owner authenticates binary/config/custody and
// binds its own case, descriptor, transaction, principal and exact effect.
type onlineObservation struct {
Schema string `json:"schema"`
Status string `json:"status"`
Current *CurrentToken `json:"current,omitempty"`
AuthenticationFacts map[string]interface{} `json:"signed_authentication_facts,omitempty"`
Inactive *InactiveForClient `json:"inactive_for_client,omitempty"`
}
func validOnlineDigest(raw string) bool {
if !strings.HasPrefix(raw, "sha256:") || len(raw) != 71 || strings.ToLower(raw) != raw {
return false
}
_, err := hex.DecodeString(raw[7:])
return err == nil
}
func parseOnlineFlags(fs *flag.FlagSet, args []string) error {
seen := map[string]bool{}
for i := 0; i < len(args); i++ {
name := strings.TrimLeft(args[i], "-")
if name == args[i] || name == "" {
return ErrOnlineToken
}
name, _, inline := strings.Cut(name, "=")
if fs.Lookup(name) == nil || seen[name] {
return ErrOnlineToken
}
seen[name] = true
if !inline {
i++
if i >= len(args) {
return ErrOnlineToken
}
}
}
if fs.Parse(args) != nil || fs.NArg() != 0 {
return ErrOnlineToken
}
return nil
}
func currentOnlineOutput(file *os.File) bool {
opened, err := file.Stat()
if err != nil {
return false
}
current, err := os.Lstat(file.Name())
return err == nil && current.Mode().IsRegular() && os.SameFile(opened, current)
}
func runOnline(ctx context.Context, mode string, args []string, stderr io.Writer) error {
fs := flag.NewFlagSet(mode, flag.ContinueOnError)
fs.SetOutput(io.Discard)
admissionFile := fs.String("admission-file", "", "independently admitted public profile")
tokenFile := fs.String("token-file", "", "private owner-acquired token JSON file")
digest := fs.String("token-sha256", "", "exact owner-bound access-token digest")
subject := fs.String("subject", "", "expected signed subject for current check")
tenant := fs.String("tenant", "", "expected signed tenant for current check")
principal := fs.String("principal-type", "", "human or service for current check")
out := fs.String("out", "", "new private observation file outside Git")
if parseOnlineFlags(fs, args) != nil || *admissionFile == "" || *tokenFile == "" || !filepath.IsAbs(*out) || !validOnlineDigest(*digest) {
return ErrOnlineToken
}
if mode == "check-current-token" && (*subject == "" || *tenant == "" || (*principal != "human" && *principal != "service")) {
return ErrOnlineToken
}
if (mode == "revoke-token" || mode == "check-inactive-token") && (*subject != "" || *tenant != "" || *principal != "") {
return ErrOnlineToken
}
raw, err := readOnlineFile(*admissionFile, false)
if err != nil {
return ErrOnlineToken
}
var admission OnlineAdmission
if _, err = decodeOnlineObject(raw, &admission); err != nil {
return ErrOnlineToken
}
client, err := NewOnlineClient(admission)
if err != nil {
return ErrOnlineToken
}
raw, err = readOnlineBoundedFile(*tokenFile, true, 131072)
if err != nil {
return ErrOnlineToken
}
var tokens Tokens
if _, err = decodeOnlineObject(raw, &tokens); err != nil || tokens.TokenType != "Bearer" || tokens.AccessToken == "" || tokenDigest(tokens.AccessToken) != *digest {
return ErrOnlineToken
}
parent, err := filepath.EvalSymlinks(filepath.Dir(*out))
if err != nil {
return ErrOnlineToken
}
info, err := os.Stat(parent)
if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return ErrOnlineToken
}
file, err := reserveOutput(*out)
if err != nil {
return ErrOnlineToken
}
success := false
defer func() {
if !success && currentOnlineOutput(file) {
_ = os.Remove(file.Name())
}
_ = file.Close()
}()
observation := onlineObservation{Schema: "keycape.current-token-observation.v1"}
switch mode {
case "check-current-token":
current, err := client.Check(ctx, tokens.AccessToken, ExpectedIdentity{Subject: *subject, Tenant: *tenant, PrincipalType: *principal})
if err != nil {
return ErrOnlineToken
}
observation.Status = "current"
observation.Current = &current
facts := current.Claims()
observation.AuthenticationFacts = map[string]interface{}{"assurance": facts["assurance"], "tenant_source": facts["tenant_source"]}
case "revoke-token":
inactive, err := client.Revoke(ctx, tokens.AccessToken)
if err != nil {
return ErrOnlineToken
}
observation.Status = "inactive-for-client"
observation.Inactive = &inactive
case "check-inactive-token":
inactive, err := client.ObserveInactive(ctx, tokens.AccessToken)
if err != nil {
return ErrOnlineToken
}
observation.Status = "inactive-for-client"
observation.Inactive = &inactive
default:
return ErrOnlineToken
}
if json.NewEncoder(file).Encode(observation) != nil || file.Sync() != nil || !currentOnlineOutput(file) {
return ErrOnlineToken
}
success = true
_, _ = fmt.Fprintln(stderr, "PASS issuer token-status observation written; execution authority remains with its owner")
return nil
}

View file

@ -0,0 +1,166 @@
package authclient
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func onlinePrivateDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if os.Chmod(dir, 0700) != nil {
t.Fatal("private fixture directory failed")
}
return dir
}
func (f *onlineFixture) cliInput(raw string) (string, string, []string) {
f.t.Helper()
dir := f.t.TempDir()
admission, token := filepath.Join(dir, "admission.json"), filepath.Join(dir, "token.json")
a, _ := json.Marshal(f.client.admission)
b, _ := json.Marshal(Tokens{AccessToken: raw, TokenType: "Bearer", ExpiresIn: 900})
if os.WriteFile(admission, a, 0644) != nil || os.WriteFile(token, b, 0600) != nil {
f.t.Fatal("fixture input write failed")
}
return admission, token, []string{"--admission-file", admission, "--token-file", token, "--token-sha256", tokenDigest(raw)}
}
func TestOnlineCLIUsesActualProducerAndPrivateObservation(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
_, _, base := f.cliInput(raw)
dir := onlinePrivateDir(t)
out := filepath.Join(dir, "current.json")
args := append([]string{"check-current-token"}, base...)
args = append(args, "--subject", onlineExpected.Subject, "--tenant", onlineExpected.Tenant, "--principal-type", "service", "--out", out)
var log bytes.Buffer
if Run(context.Background(), args, &log) != nil {
t.Fatal("current-token command failed")
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal("observation unavailable")
}
var observation onlineObservation
if json.Unmarshal(data, &observation) != nil || observation.Schema != "keycape.current-token-observation.v1" || observation.Status != "current" || observation.Current == nil || observation.Current.TokenDigest != tokenDigest(raw) || observation.AuthenticationFacts["assurance"] == nil {
t.Fatal("wrong producer observation")
}
info, _ := os.Stat(out)
if info.Mode().Perm() != 0600 {
t.Fatal("observation is public")
}
if strings.Contains(string(data), raw) || strings.Contains(log.String(), raw) || strings.Contains(log.String(), onlineExpected.Subject) || strings.Contains(log.String(), f.issuer.ClientConfig["current-client"].ClientSecret) {
t.Fatal("command exposed private facts")
}
if Run(context.Background(), args, &log) == nil {
t.Fatal("existing observation overwritten")
}
currentBefore, _ := os.ReadFile(out)
if !bytes.Equal(currentBefore, data) {
t.Fatal("prior observation changed")
}
revokedOut := filepath.Join(dir, "inactive.json")
args = append([]string{"revoke-token"}, base...)
args = append(args, "--out", revokedOut)
if Run(context.Background(), args, &log) != nil {
t.Fatal("cleanup observation failed")
}
data, _ = os.ReadFile(revokedOut)
if json.Unmarshal(data, &observation) != nil || observation.Status != "inactive-for-client" || observation.Inactive == nil || observation.Inactive.TokenDigest != tokenDigest(raw) {
t.Fatal("cleanup scope not explicit")
}
failedOut := filepath.Join(dir, "failed.json")
args = append([]string{"check-current-token"}, base...)
args = append(args, "--subject", onlineExpected.Subject, "--tenant", onlineExpected.Tenant, "--principal-type", "service", "--out", failedOut)
if Run(context.Background(), args, &log) == nil {
t.Fatal("revoked token accepted by actual command")
}
if _, err = os.Stat(failedOut); !os.IsNotExist(err) {
t.Fatal("failed command retained positive output")
}
}
func TestOnlineCLIRejectsChangedTokenAndUntrustedInput(t *testing.T) {
for _, kind := range []string{"wrong-hash", "duplicate-flag", "token-replaced", "duplicate-admission", "duplicate-token", "public-token", "shared-output-parent", "git-output", "output-symlink", "unknown-flag", "cleanup-identity-flags"} {
t.Run(kind, func(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
admission, token, base := f.cliInput(raw)
dir := onlinePrivateDir(t)
out := filepath.Join(dir, "result.json")
mode := "check-current-token"
extra := []string{"--subject", onlineExpected.Subject, "--tenant", onlineExpected.Tenant, "--principal-type", "service"}
switch kind {
case "wrong-hash":
base[len(base)-1] = "sha256:" + strings.Repeat("0", 64)
case "duplicate-flag":
base = append(base, "--token-sha256", tokenDigest(raw))
case "token-replaced":
other := f.issue()
b, _ := json.Marshal(Tokens{AccessToken: other, TokenType: "Bearer"})
_ = os.WriteFile(token, b, 0600)
case "duplicate-admission":
b, _ := os.ReadFile(admission)
b = append([]byte(`{"issuer":"https://forged.invalid",`), b[1:]...)
_ = os.WriteFile(admission, b, 0644)
case "duplicate-token":
b, _ := os.ReadFile(token)
b = append([]byte(`{"token_type":"Bearer",`), b[1:]...)
_ = os.WriteFile(token, b, 0600)
case "public-token":
_ = os.Chmod(token, 0644)
case "shared-output-parent":
_ = os.Chmod(dir, 0755)
case "git-output":
_ = os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: unused"), 0600)
case "output-symlink":
target := filepath.Join(t.TempDir(), "untouched")
_ = os.WriteFile(target, []byte("original"), 0600)
_ = os.Symlink(target, out)
case "unknown-flag":
extra = append(extra, "--secret-raw=must-not-echo-this")
case "cleanup-identity-flags":
mode = "revoke-token"
}
args := append([]string{mode}, base...)
args = append(args, extra...)
args = append(args, "--out", out)
var log bytes.Buffer
if Run(context.Background(), args, &log) == nil || f.introspections.Load() != 0 || f.revocations.Load() != 0 {
t.Fatal("untrusted command input reached issuer")
}
if strings.Contains(log.String(), "must-not-echo-this") || strings.Contains(log.String(), raw) {
t.Fatal("refusal echoed input")
}
})
}
}
func TestOnlineCLIFailureDoesNotDeleteReplacedObservation(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
_, _, base := f.cliInput(raw)
dir := onlinePrivateDir(t)
out := filepath.Join(dir, "result.json")
f.afterStatus.Store(func() {
if os.Remove(out) != nil || os.WriteFile(out, []byte("replacement owned by another attempt"), 0600) != nil {
t.Error("replacement fixture failed")
}
})
args := append([]string{"check-current-token"}, base...)
args = append(args, "--subject", onlineExpected.Subject, "--tenant", onlineExpected.Tenant, "--principal-type", "service", "--out", out)
var log bytes.Buffer
if Run(context.Background(), args, &log) == nil {
t.Fatal("replaced observation reported successful")
}
data, _ := os.ReadFile(out)
if string(data) != "replacement owned by another attempt" {
t.Fatal("another attempt's output removed")
}
}

View file

@ -0,0 +1,20 @@
package authclient
import (
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestOnlineExportMatchesItsSingleProducer(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("producer source location unavailable")
}
root := filepath.Clean(filepath.Join(filepath.Dir(file), "../../.."))
cmd := exec.Command("python3", filepath.Join(root, "tools/export_tokenstatus_client.py"), "--check")
if err := cmd.Run(); err != nil {
t.Fatal("public adapter differs from the single producer source")
}
}

View file

@ -0,0 +1,78 @@
package authclient
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
)
type onlineHumanDirectory struct{ user domain.User }
func (d *onlineHumanDirectory) LookupUser(context.Context, string) (*domain.User, error) {
return &d.user, nil
}
func (d *onlineHumanDirectory) LookupGroups(context.Context, string) ([]domain.Group, error) {
return nil, nil
}
func (d *onlineHumanDirectory) ListUsers(context.Context) ([]domain.User, error) {
return []domain.User{d.user}, nil
}
func (d *onlineHumanDirectory) ValidatePassword(context.Context, string, string) (bool, error) {
return false, nil
}
func TestOnlineActualHumanPKCEFactsStayDistinctFromCurrentStatus(t *testing.T) {
f := onlineProvider(t)
directory := &onlineHumanDirectory{user: domain.User{ID: "uid=operator,ou=people,dc=fixture", Username: "operator", Enabled: true, Tenant: "tenant:fixture", Groups: []string{"fixture-allowed"}}}
client := f.issuer.ClientConfig["current-client"]
client.GrantTypes = []string{"authorization_code"}
client.ServiceSubject = ""
client.AllowedScopes = []string{"openid", "profile", "groups"}
client.RedirectURIs = []string{"https://owner.fixture.invalid/callback"}
f.issuer.Users = directory
f.status.Users = directory
verifier := strings.Repeat("v", 43)
hash := sha256.Sum256([]byte(verifier))
authenticated := time.Now().Add(-time.Minute).Truncate(time.Second)
code := f.issuer.Sessions.Create(&oidc.PKCESession{ClientID: client.ClientID, Username: directory.user.Username,
RedirectURI: client.RedirectURIs[0], PKCEChallengeMethod: "S256", PKCEChallenge: base64.RawURLEncoding.EncodeToString(hash[:]),
Scopes: client.AllowedScopes, MFAVerified: true, AuthTime: authenticated, ExpiresAt: time.Now().Add(time.Minute)})
form := url.Values{"grant_type": {"authorization_code"}, "client_id": {client.ClientID}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {client.RedirectURIs[0]}}
r := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.SetBasicAuth(url.QueryEscape(client.ClientID), url.QueryEscape(client.ClientSecret))
w := httptest.NewRecorder()
f.issuer.ServeHTTP(w, r)
var tokens Tokens
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &tokens) != nil {
t.Fatal("actual human PKCE fixture failed")
}
expected := ExpectedIdentity{Subject: directory.user.ID, Tenant: directory.user.Tenant, PrincipalType: "human"}
current, err := f.client.Check(context.Background(), tokens.AccessToken, expected)
if err != nil {
t.Fatal("actual human current status failed")
}
facts := current.Claims()
assurance, ok := facts["assurance"].(map[string]interface{})
if !ok || assurance["level"] != "aal2" || assurance["mfa"] != true || assurance["at"] != float64(authenticated.Unix()) || facts["tenant_source"] != "directory" {
t.Fatal("signed authentication provenance changed or was upgraded to current time")
}
before := f.introspections.Load()
if _, err = f.client.Check(context.Background(), tokens.IDToken, expected); err == nil || f.introspections.Load() != before {
t.Fatal("ID token was substituted for access-token status")
}
directory.user.Groups = []string{"netkingdom-suspended"}
if _, err = f.client.Check(context.Background(), tokens.AccessToken, expected); err == nil {
t.Fatal("suspended human remained current")
}
}

View file

@ -0,0 +1,463 @@
package authclient
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/jose"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
type onlineFixture struct {
t *testing.T
client *OnlineClient
issuer *oidc.TokenHandler
status *oidc.TokenStatusHandler
server *httptest.Server
mode atomic.Value
introspections atomic.Int64
revocations atomic.Int64
afterStatus atomic.Value
}
func onlineProvider(t *testing.T) *onlineFixture {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal("fixture key creation failed")
}
store, _ := oidc.NewTokenStatusStore(128)
f := &onlineFixture{t: t}
f.mode.Store("")
f.afterStatus.Store(func() {})
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.serve(w, r) }))
t.Cleanup(server.Close)
f.server = server
clients := map[string]*domain.Client{
"current-client": {ClientID: "current-client", ClientType: "confidential", ClientSecret: "synthetic + % /: current", Audience: "resource:fixture", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"fixture.read"}, ServiceSubject: "service:fixture", Tenant: "tenant:fixture"},
"other-client": {ClientID: "other-client", ClientType: "confidential", ClientSecret: "synthetic other", Audience: "resource:fixture", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"fixture.read"}, ServiceSubject: "service:fixture", Tenant: "tenant:fixture"},
}
f.issuer = &oidc.TokenHandler{ClientConfig: clients, Sessions: oidc.NewSessionStore(), SigningKey: key, Issuer: server.URL, TokenLifetime: 15 * time.Minute, Emitter: telemetry.NoopEmitter{}, Tokens: store}
f.status = &oidc.TokenStatusHandler{Tokens: store, ClientConfig: clients, SigningKey: &key.PublicKey, Issuer: server.URL}
dir := t.TempDir()
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
caFile, keysFile, secretFile := filepath.Join(dir, "ca.pem"), filepath.Join(dir, "keys.json"), filepath.Join(dir, "caller")
if os.WriteFile(caFile, ca, 0644) != nil || os.WriteFile(secretFile, []byte(clients["current-client"].ClientSecret), 0600) != nil {
t.Fatal("fixture files failed")
}
keys := oidc.NewKeySet()
keys.AddKey("key-1", &key.PublicKey)
w := httptest.NewRecorder()
oidc.NewJWKSHandler(keys).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/jwks", nil))
if os.WriteFile(keysFile, w.Body.Bytes(), 0644) != nil {
t.Fatal("fixture public keys failed")
}
f.client, err = NewOnlineClient(OnlineAdmission{Issuer: server.URL, Audience: "resource:fixture", ClientID: "current-client", CAFile: caFile, JWKSFile: keysFile, SecretFile: secretFile, MaxTokenAge: "5m"})
if err != nil {
t.Fatal("fixture admission failed")
}
return f
}
func (f *onlineFixture) serve(w http.ResponseWriter, r *http.Request) {
mode := f.mode.Load().(string)
switch r.URL.Path {
case "/token":
f.issuer.ServeHTTP(w, r)
case "/revoke":
f.revocations.Add(1)
if mode == "revoke-no-effect" {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
if mode == "revoke-body" {
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, "unexpected")
return
}
f.status.Revoke(w, r)
case "/introspect":
f.introspections.Add(1)
if mode == "redirect" {
http.Redirect(w, r, f.server.URL+"/sink", 307)
return
}
if mode == "unavailable" {
w.WriteHeader(503)
return
}
if mode == "oversize" {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, strings.Repeat("x", 65537))
return
}
recorded := httptest.NewRecorder()
f.status.Introspect(recorded, r)
for key, values := range recorded.Header() {
for _, value := range values {
w.Header().Add(key, value)
}
}
if mode == "cached" {
w.Header().Set("Cache-Control", "max-age=30")
}
if mode == "wrong-content-type" {
w.Header().Set("Content-Type", "text/plain")
}
var body map[string]interface{}
if json.Unmarshal(recorded.Body.Bytes(), &body) != nil {
w.WriteHeader(recorded.Code)
_, _ = w.Write(recorded.Body.Bytes())
return
}
switch mode {
case "missing-active":
delete(body, "active")
case "wrong-active-type":
body["active"] = "true"
case "missing-scope":
delete(body, "scope")
case "null-scope":
body["scope"] = nil
case "wrong-client":
body["client_id"] = "other-client"
case "wrong-subject":
body["sub"] = "service:other"
case "wrong-issuer":
body["iss"] = "https://other.invalid"
case "wrong-audience":
body["aud"] = "resource:other"
case "wrong-jti":
body["jti"] = "different-token-id"
case "wrong-expiry":
body["exp"] = body["exp"].(float64) + 10
case "wrong-issued":
body["iat"] = body["iat"].(float64) - 1
case "wrong-scope":
body["scope"] = "different.scope"
case "wrong-token-type":
body["token_type"] = "Other"
case "unknown-field":
body["authority"] = true
}
f.afterStatus.Load().(func())()
w.WriteHeader(recorded.Code)
encoded, _ := json.Marshal(body)
if mode == "duplicate-active" {
encoded = append([]byte(`{"active":false,`), encoded[1:]...)
}
if mode == "trailing-json" {
encoded = append(encoded, []byte(`{}`)...)
}
_, _ = w.Write(encoded)
case "/sink":
f.t.Error("credential redirect followed")
w.WriteHeader(500)
default:
w.WriteHeader(404)
}
}
func (f *onlineFixture) issue() string {
f.t.Helper()
r := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := f.issuer.ClientConfig["current-client"]
r.SetBasicAuth(url.QueryEscape(client.ClientID), url.QueryEscape(client.ClientSecret))
w := httptest.NewRecorder()
f.issuer.ServeHTTP(w, r)
var token Tokens
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &token) != nil || token.AccessToken == "" {
f.t.Fatal("actual fixture issuance failed")
}
return token.AccessToken
}
var onlineExpected = ExpectedIdentity{Subject: "service:fixture", Tenant: "tenant:fixture", PrincipalType: "service"}
func TestOnlineActualIssuerCurrentRevocationAndFreshGrant(t *testing.T) {
f := onlineProvider(t)
first := f.issue()
ctx := context.Background()
identity, err := f.client.Check(ctx, first, onlineExpected)
if err != nil || identity.Subject != onlineExpected.Subject || identity.ClientID != "current-client" || identity.TokenDigest != tokenDigest(first) || identity.TokenID == "" || !identity.CheckedAt.Before(identity.NotAfter) {
t.Fatal("current exact identity not established")
}
claims := identity.Claims()
claims["sub"] = "changed"
if identity.Claims()["sub"] != onlineExpected.Subject {
t.Fatal("returned facts share mutable state")
}
if _, err = f.client.Check(ctx, first, onlineExpected); err != nil || f.introspections.Load() != 2 {
t.Fatal("current status was cached")
}
if _, err = f.client.ObserveInactive(ctx, first); err == nil || f.revocations.Load() != 0 {
t.Fatal("read-only negative check changed active authority")
}
closed, err := f.client.Revoke(ctx, first)
if err != nil || closed.ClientID != "current-client" || closed.TokenDigest != identity.TokenDigest {
t.Fatal("issuer negative cleanup readback failed")
}
if _, err = f.client.Check(ctx, first, onlineExpected); err == nil {
t.Fatal("revoked token accepted")
}
before := f.revocations.Load()
if _, err = f.client.ObserveInactive(ctx, first); err != nil || f.revocations.Load() != before {
t.Fatal("independent negative readback failed or mutated token")
}
second := f.issue()
current, err := f.client.Check(ctx, second, onlineExpected)
if err != nil || current.TokenID == identity.TokenID || current.TokenDigest == identity.TokenDigest {
t.Fatal("fresh grant did not establish distinct identity")
}
}
func TestOnlineIdentityMismatchRefusesBeforeNetwork(t *testing.T) {
for _, kind := range []string{"subject", "tenant", "principal", "audience", "issuer", "tamper", "key", "no-jti", "ID-token", "stale"} {
t.Run(kind, func(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
expect := onlineExpected
switch kind {
case "subject":
expect.Subject = "other"
case "tenant":
expect.Tenant = "other"
case "principal":
expect.PrincipalType = "human"
case "audience":
f.client.admission.Audience = "other"
case "issuer":
f.client.admission.Issuer = "https://different.invalid"
case "tamper":
raw += "changed"
case "key":
key, _ := rsa.GenerateKey(rand.Reader, 2048)
raw = signToken(t, key, "key-1", map[string]interface{}{"iss": f.server.URL})
case "no-jti":
f.issuer.Tokens = nil
raw = f.issue()
case "ID-token":
identity, err := f.client.verify(raw, expect, time.Now())
if err != nil {
t.Fatal("fixture identity failed")
}
claims := identity.Claims()
delete(claims, "scope")
raw, err = oidc.BuildJWT(claims, "key-1", f.issuer.SigningKey)
if err != nil {
t.Fatal("fixture purpose token failed")
}
case "stale":
f.client.now = func() time.Time { return time.Now().Add(10 * time.Minute) }
}
if _, err := f.client.Check(context.Background(), raw, expect); !errors.Is(err, ErrOnlineToken) || f.introspections.Load() != 0 {
t.Fatal("unbound identity reached credential-bearing endpoint")
}
})
}
}
func TestOnlineRefusesAmbiguousOrMismatchedCurrentStatus(t *testing.T) {
for _, mode := range []string{"missing-active", "wrong-active-type", "missing-scope", "null-scope", "wrong-client", "wrong-subject", "wrong-issuer", "wrong-audience", "wrong-jti", "wrong-expiry", "wrong-issued", "wrong-scope", "wrong-token-type", "unknown-field", "duplicate-active", "trailing-json", "redirect", "unavailable", "oversize", "cached", "wrong-content-type"} {
t.Run(mode, func(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
f.mode.Store(mode)
if _, err := f.client.Check(context.Background(), raw, onlineExpected); !errors.Is(err, ErrOnlineToken) || err.Error() != ErrOnlineToken.Error() {
t.Fatal("unsafe response accepted or disclosed detail")
}
})
}
}
func TestOnlineCleanupRequiresNegativeReadbackAndPreservesScope(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
f.mode.Store("revoke-no-effect")
if _, err := f.client.Revoke(context.Background(), raw); err == nil {
t.Fatal("empty 200 mistaken for confirmed inactive state")
}
f.mode.Store("revoke-body")
if _, err := f.client.Revoke(context.Background(), raw); err == nil {
t.Fatal("unexpected cleanup output accepted")
}
f.mode.Store("")
f.client.admission.ClientID = "other-client"
if os.WriteFile(f.client.admission.SecretFile, []byte("synthetic other"), 0600) != nil {
t.Fatal("fixture credential write failed")
}
result, err := f.client.Revoke(context.Background(), raw)
if err != nil || result.ClientID != "other-client" {
t.Fatal("wrong-client negative scope not explicit")
}
f.client.admission.ClientID = "current-client"
_ = os.WriteFile(f.client.admission.SecretFile, []byte("synthetic + % /: current"), 0600)
if _, err = f.client.Check(context.Background(), raw, onlineExpected); err != nil {
t.Fatal("another client acquired revocation authority")
}
}
func TestOnlineReloadsCredentialsKeysAndTrustFiles(t *testing.T) {
for _, kind := range []string{"secret", "keys", "ca", "secret-world-readable", "public-writable", "secret-fifo"} {
t.Run(kind, func(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
if _, err := f.client.Check(context.Background(), raw, onlineExpected); err != nil {
t.Fatal("fixture current check failed")
}
switch kind {
case "secret":
_ = os.WriteFile(f.client.admission.SecretFile, []byte("changed synthetic"), 0600)
case "keys":
_ = os.WriteFile(f.client.admission.JWKSFile, []byte(`{"keys":[]}`), 0644)
case "ca":
_ = os.WriteFile(f.client.admission.CAFile, []byte("not a certificate"), 0644)
case "secret-world-readable":
_ = os.Chmod(f.client.admission.SecretFile, 0644)
case "public-writable":
_ = os.Chmod(f.client.admission.JWKSFile, 0666)
case "secret-fifo":
_ = os.Remove(f.client.admission.SecretFile)
if syscall.Mkfifo(f.client.admission.SecretFile, 0600) != nil {
t.Fatal("FIFO fixture failed")
}
}
if _, err := f.client.Check(context.Background(), raw, onlineExpected); err == nil {
t.Fatal("withdrawn or unsafe input reused")
}
})
}
}
func TestOnlineRechecksExpiryClockAndKeyAfterStatus(t *testing.T) {
for _, kind := range []string{"expiry", "clock-backwards", "withdrawn-key"} {
t.Run(kind, func(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
now := time.Now()
calls := 0
f.client.now = func() time.Time {
calls++
if calls == 1 {
return now
}
if kind == "expiry" {
return now.Add(10 * time.Minute)
}
if kind == "clock-backwards" {
return now.Add(-time.Second)
}
return now
}
if kind == "withdrawn-key" {
f.afterStatus.Store(func() { _ = os.WriteFile(f.client.admission.JWKSFile, []byte(`{"keys":[]}`), 0644) })
}
if _, err := f.client.Check(context.Background(), raw, onlineExpected); err == nil || f.introspections.Load() != 1 {
t.Fatal("delayed status preserved withdrawn identity")
}
})
}
}
func TestOnlineCancellationAndAmbientProxyDoNotLeakInputs(t *testing.T) {
f := onlineProvider(t)
if f.server.Certificate().VerifyHostname("example.com") != nil {
t.Fatal("fixture certificate lacks test DNS identity")
}
original, _ := url.Parse(f.server.URL)
_, port, _ := net.SplitHostPort(original.Host)
dnsAddress := net.JoinHostPort("example.com", port)
f.client.admission.Issuer = "https://" + dnsAddress
f.issuer.Issuer = f.client.admission.Issuer
f.status.Issuer = f.client.admission.Issuer
f.client.dialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
if address == dnsAddress {
address = original.Host
}
return (&net.Dialer{}).DialContext(ctx, network, address)
}
raw := f.issue()
proxyCalls := atomic.Int64{}
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyCalls.Add(1); w.WriteHeader(500) }))
defer proxy.Close()
t.Setenv("HTTPS_PROXY", proxy.URL)
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("ALL_PROXY", proxy.URL)
t.Setenv("NO_PROXY", "")
if _, err := f.client.Check(context.Background(), raw, onlineExpected); err != nil || proxyCalls.Load() != 0 {
t.Fatal("ambient proxy affected explicit owner transport")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
before := f.introspections.Load()
if _, err := f.client.Check(ctx, raw, onlineExpected); err == nil || f.introspections.Load() != before {
t.Fatal("cancelled check reached provider")
}
if _, err := f.client.Revoke(ctx, raw); err == nil || f.revocations.Load() != 0 {
t.Fatal("cancelled cleanup reached provider")
}
}
func TestOnlineMalformedAdmissionAndNegativeStatus(t *testing.T) {
f := onlineProvider(t)
for _, issuer := range []string{"http://issuer.invalid", "https://user@issuer.invalid", "https://issuer.invalid?", "https://issuer.invalid#", "https://issuer.invalid/../other", "https://issuer.invalid/%2Fother", "https://issuer.invalid/"} {
a := f.client.admission
a.Issuer = issuer
if _, err := NewOnlineClient(a); err == nil {
t.Fatal("ambiguous issuer admitted")
}
}
for _, raw := range []string{`null`, `[]`, `{}`, `{"active":null}`, `{"active":false,"sub":"hidden"}`, `{"active":false,"active":false}`, `{"active":false}{}`} {
if _, err := decodeOnlineStatus([]byte(raw)); err == nil {
t.Fatal("ambiguous inactive response accepted")
}
}
if status, err := decodeOnlineStatus([]byte(`{"active":false}`)); err != nil || *status.Active {
t.Fatal("exact inactive response refused")
}
}
func TestOnlineUsesSharedTypedJOSEVerifier(t *testing.T) {
f := onlineProvider(t)
raw := f.issue()
data, err := os.ReadFile(f.client.admission.JWKSFile)
if err != nil {
t.Fatal("fixture public keys unavailable")
}
keys, err := jose.ParseJWKS(data)
if err != nil {
t.Fatal("fixture public keys invalid")
}
if _, err = jose.VerifyTyped(raw, keys, "JWT"); err != nil {
t.Fatal("actual producer token rejected")
}
for _, typ := range []string{"", "at+jwt", "other"} {
if _, err = jose.VerifyTyped(raw, keys, typ); err == nil {
t.Fatal("unadmitted JWT type accepted")
}
}
if _, err = jose.Verify(raw, keys); err != nil {
t.Fatal("legacy shared signature verifier changed")
}
}

View file

@ -92,10 +92,27 @@ func KeyID(token string) (string, error) {
// Verify checks a token's RS256 signature against the key set and returns its
// claims. It does not inspect any claim.
func Verify(token string, keys KeySet) (map[string]interface{}, error) {
return verify(token, keys, "")
}
// VerifyTyped applies the same signature verifier with an explicit JOSE type.
// Existing Verify callers retain their claim/type policy; online access-token
// consumers opt into the producer's exact JWT header contract here.
func VerifyTyped(token string, keys KeySet, expectedType string) (map[string]interface{}, error) {
if expectedType == "" {
return nil, ErrVerification
}
return verify(token, keys, expectedType)
}
func verify(token string, keys KeySet, expectedType string) (map[string]interface{}, error) {
header, signingInput, signature, err := split(token)
if err != nil {
return nil, err
}
if expectedType != "" && header.Typ != expectedType {
return nil, ErrVerification
}
public, ok := keys[header.Kid]
if !ok {
return nil, ErrVerification
@ -117,6 +134,7 @@ func Verify(token string, keys KeySet) (map[string]interface{}, error) {
type joseHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
Kid string `json:"kid"`
Crit []string `json:"crit"`
}