Share current-token verification and scoped cleanup through one producer client
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
parent
67a0869c1e
commit
08c083bae9
19 changed files with 2228 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
379
src/internal/authclient/online.go
Normal file
379
src/internal/authclient/online.go
Normal 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
|
||||
}
|
||||
160
src/internal/authclient/online_cli.go
Normal file
160
src/internal/authclient/online_cli.go
Normal 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 = ¤t
|
||||
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
|
||||
}
|
||||
166
src/internal/authclient/online_cli_test.go
Normal file
166
src/internal/authclient/online_cli_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
20
src/internal/authclient/online_export_test.go
Normal file
20
src/internal/authclient/online_export_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
78
src/internal/authclient/online_human_test.go
Normal file
78
src/internal/authclient/online_human_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
463
src/internal/authclient/online_test.go
Normal file
463
src/internal/authclient/online_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue