Add native verified login and service-token commands
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b
This commit is contained in:
tegwick 2026-09-05 01:08:58 +02:00
parent 66df5fcf07
commit b989de4e90
12 changed files with 928 additions and 14 deletions

View file

@ -267,3 +267,12 @@ Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under
domain `netkingdom`, repo slug `key-cape`.
See `CLAUDE.md` for agent session protocol and workplan conventions.
## Native authentication commands
`keycape service-token` exchanges a custody-injected client secret for a verified
JWT; `keycape login` performs public-client browser PKCE login. Both save tokens
to a new private file outside Git and never print token values. See
[Native caller authentication](docs/native-authentication.md) for usage, supported
bindings, renewal, and the distinction from OpenBao login.

View file

@ -17,12 +17,12 @@
| workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
| workplan | KEY-WP-0007 | finished | — | workplans/KEY-WP-0007-user-engine-portal-oidc-client.md |
| workplan | KEY-WP-0008 | finished | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
| workplan | KEY-WP-0009 | finished | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| workplan | KEY-WP-0009 | blocked | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| workplan | KEY-WP-0010 | finished | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md |
| workplan | KEY-WP-0011 | finished | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| workplan | KEY-WP-0012 | finished | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| workplan | KEY-WP-0013 | blocked | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| workplan | KEY-WP-0014 | proposed | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| workplan | KEY-WP-0014 | blocked | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md |
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
@ -83,7 +83,7 @@
| task | KEY-WP-0009-T01 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| task | KEY-WP-0009-T02 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| task | KEY-WP-0009-T03 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| task | KEY-WP-0009-T04 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| task | KEY-WP-0009-T04 | wait | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| task | KEY-WP-0010-T01 | done | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md |
| task | KEY-WP-0011-T01 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| task | KEY-WP-0011-T02 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
@ -93,6 +93,8 @@
| task | KEY-WP-0012-T03 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| task | KEY-WP-0013-T01 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0013-T02 | wait | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0014-T01 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T02 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T01 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T02 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T03 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T04 | wait | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| intake | KEY-IN-0001 | closed | — | intakes/intakes.md |

View file

@ -0,0 +1,45 @@
# Proposed non-secret admission packet. This is not executable authorization.
status: awaiting-custody-admission
owner: key-cape
resource_audience: approval-engine
issuer: https://kc.coulomb.social
registration_source: config/service-clients.example.yaml
requests:
- client_id: secrets-engine-approval
subject: service:secrets-engine
tenant: tenant:coulomb
scopes: [approval:read, approval:consume]
lifetime: 15m
proposed_openbao_path: platform/workloads/secrets-engine/approval-client
field: client_secret
proposed_kubernetes_secret: sso/keycape-secrets-engine-approval-client
kubernetes_key: client-secret
keycape_environment: KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
custody_owner: railiance-platform
consumer: secrets-engine
- client_id: approval-engine-operator
subject: service:approval-engine-operator
tenant: tenant:coulomb
scopes: [approval:create, approval:read, approval:approve, approval:revoke, approval:supersede, approval:observe, approval:emit]
lifetime: 15m
proposed_openbao_path: platform/workloads/approval-engine/operator-client
field: client_secret
proposed_kubernetes_secret: sso/keycape-approval-engine-operator-client
kubernetes_key: client-secret
keycape_environment: KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
custody_owner: railiance-platform
consumer: approval-engine-operator
human_registration:
status: awaiting-exact-callback
scopes: [openid, approval:approve]
mfa_required: true
client_type: public
verification:
- Validate the new signature against deployed JWKS and all exact claim bindings.
- Reject wrong secrets, operator consume, PEP lifecycle scopes, and human consume.
- Check KeyCape and consumer readiness without emitting secrets or tokens.
- Preserve existing registrations and signing key; record versions and image digest.
blockers:
- Admit exact custody paths, field delivery, consumer identities and lifecycle authority.
- Resolve attended first-provision authority through the custody owner.
- Supply exact human client ID and callback URI.

View file

@ -0,0 +1,87 @@
# Native caller authentication
KeyCape owns the `keycape service-token` and `keycape login` commands. They
produce issuer-verified JWTs in a new mode-0600 JSON file outside Git worktrees.
Existing files and symlinks are refused. Token responses and provider error
bodies are never printed. Commands exit nonzero on verification or delivery
failure and remove incomplete output files.
## Service exchange
Have the existing custody mechanism inject the registered client secret into a
named environment variable, then run:
```sh
keycape service-token --issuer https://kc.coulomb.social \
--client-id rapp-qonto --scope qonto:read \
--secret-env KEYCAPE_RAPP_QONTO_CLIENT_SECRET \
--out /private/runtime/qonto-token.json
```
The output directory must already exist. No secret belongs on argv. The command
uses client_secret_basic with OAuth form encoding. The server must include the
matching decoding fix for secrets containing reserved characters. The Qonto
client ID and scope above follow the deployed KEY-WP-0006 registration.
For approval-engine clients, set `--audience approval-engine` explicitly; the
expected audience otherwise defaults to the client ID. Both requested scopes
and audience must match the issuer's static registration. The output contains
`access_token`, `token_type`, and `expires_in`; a service exchange has no ID token.
Re-exchange before expiry for renewal. There is no refresh token or credential
cache. Client disablement stops new issuance; already-issued JWTs expire normally.
## Browser login
Use a registered **public PKCE** client with an exact literal-loopback callback:
```sh
keycape login --issuer https://kc.coulomb.social \
--client-id APPROVER_CLIENT_ID --audience approval-engine \
--scope 'openid approval:approve' \
--redirect-uri http://127.0.0.1:REGISTERED_PORT/callback \
--out /private/runtime/approver-token.json
```
Replace the client ID and port with the approved registration. This command
prints a browser authorization URL and waits up to five minutes. The provider
handles password and MFA; KeyCape CLI never receives those credentials. Configure
`mfaRequired: true` on the approver registration. State, nonce and S256 PKCE are
fresh for each attempt. The listener binds before the URL is printed, validates
callback host/path/state, and closes when the attempt ends.
Both tokens are verified against same-origin HTTPS discovery/JWKS. Access tokens
bind the expected resource audience and requested scopes. ID tokens bind the
client ID, nonce and matching subject. There is no insecure TLS switch and no
HTTP redirect following during credential exchange. Cross-origin discovery
endpoints and confidential browser clients are intentionally unsupported.
## OpenBao login boundary
The existing ops-warden login route runs
`bao login -no-print -method=oidc -path=netkingdom role=<domain>`. That produces an
**OpenBao token**, unlike KeyCape's new issuer JWT output. These commands are not
interchangeable. KeyCape owns browser authentication and issuer JWTs; OpenBao's
role mapping, token store and enforcement remain platform-owned. Do not replace
that route with `keycape login` without adapting and verifying its consumer
contract. No ops-warden route is retired by this change.
## Qonto rotation boundary
Native exchange is implemented. Rotation still needs a coordinated custody and
provider update, not a wrapper around a raw KV read. The reviewed sequence is:
1. Resolve exact existing authority for
`platform/workloads/rapp-qonto/keycape-client`, field `client_secret`, and
`sso/keycape-rapp-qonto-client`, key `client-secret`.
2. Generate the successor inside the approved custody execution transport;
retain the predecessor only there for bounded rollback and denial checks.
3. CAS-update that KV field while preserving siblings, update the matching
Kubernetes delivery reference, and restart KeyCape in the agreed window.
4. Verify the new `qonto:read` exchange, predecessor rejection and excess-scope
denial without exposing tokens; verify downstream readiness.
5. On failure, reconcile both custodians to the same version and reload KeyCape
before declaring rollback complete. Record versions and outcomes, never values.
No general rotation command is shipped until that cross-owner transaction has
an admitted execution and rollback contract. Calling secrets-engine's KV
rotation alone would leave the provider and consumers inconsistent.

View file

@ -21,6 +21,7 @@ import (
"keycape/internal/adapters/authelia"
"keycape/internal/adapters/lldap"
"keycape/internal/adapters/privacyidea"
"keycape/internal/authclient"
"keycape/internal/config"
"keycape/internal/domain"
servererrors "keycape/internal/server/errors"
@ -31,6 +32,14 @@ import (
const version = "0.1.0"
func main() {
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token") {
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
// -----------------------------------------------------------------

View file

@ -0,0 +1,227 @@
package authclient
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// Run executes a caller command. Output contains only instructions or status.
func Run(ctx context.Context, args []string, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("expected login or service-token")
}
mode := args[0]
if mode != "login" && mode != "service-token" {
return errors.New("unknown authentication command")
}
fs := flag.NewFlagSet(mode, flag.ContinueOnError)
fs.SetOutput(stderr)
issuer := fs.String("issuer", "", "HTTPS issuer")
id := fs.String("client-id", "", "registered client ID")
audience := fs.String("audience", "", "expected access audience (defaults to client ID)")
scope := fs.String("scope", "", "space-separated registered scopes")
secretEnv := fs.String("secret-env", "", "environment variable containing service client secret")
out := fs.String("out", "", "new private JSON token file outside Git")
redirect := fs.String("redirect-uri", "", "exact registered HTTP loopback callback (login only)")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if fs.NArg() != 0 || *id == "" || *out == "" || strings.TrimSpace(*scope) == "" {
return errors.New("client-id, scope and out are required; positional arguments are not accepted")
}
if *audience == "" {
*audience = *id
}
if mode == "login" && (*secretEnv != "" || !hasScope(*scope, "openid")) {
return errors.New("login requires openid scope and a public PKCE client")
}
if mode == "service-token" && (*secretEnv == "" || *redirect != "") {
return errors.New("service-token requires secret-env and does not accept redirect-uri")
}
c, err := New(*issuer)
if err != nil {
return err
}
file, err := reserveOutput(*out)
if err != nil {
return err
}
success := false
defer func() {
file.Close()
if !success {
os.Remove(file.Name())
}
}()
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
d, err := c.Discover(ctx)
if err != nil {
return err
}
var tokens Tokens
if mode == "service-token" {
secret := os.Getenv(*secretEnv)
if secret == "" {
return errors.New("client secret environment variable is empty")
}
tokens, err = c.Exchange(ctx, d, url.Values{"grant_type": {"client_credentials"}, "scope": {*scope}}, *id, secret, *audience, "")
} else {
tokens, err = c.Login(ctx, d, *id, *audience, *scope, *redirect, stderr)
}
if err != nil {
return err
}
if err = json.NewEncoder(file).Encode(tokens); err != nil {
return errors.New("could not write token file")
}
if err = file.Sync(); err != nil {
return errors.New("could not sync token file")
}
if err = file.Close(); err != nil {
return errors.New("could not close token file")
}
success = true
fmt.Fprintln(stderr, "Verified tokens saved to the requested private file.")
return nil
}
func reserveOutput(path string) (*os.File, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, errors.New("invalid output path")
}
parent, err := filepath.EvalSymlinks(filepath.Dir(absolute))
if err != nil {
return nil, errors.New("output directory must already exist")
}
for dir := parent; ; dir = filepath.Dir(dir) {
marker := filepath.Join(dir, ".git")
info, statErr := os.Lstat(marker)
if statErr == nil {
// A worktree uses a .git file; normal repositories have .git/HEAD.
// An empty directory alone is not a Git repository.
if !info.IsDir() {
return nil, errors.New("token output must be outside Git worktrees")
}
if _, err := os.Lstat(filepath.Join(marker, "HEAD")); err == nil {
return nil, errors.New("token output must be outside Git worktrees")
} else if !os.IsNotExist(err) {
return nil, errors.New("could not inspect Git directory")
}
} else if !os.IsNotExist(statErr) {
return nil, errors.New("could not inspect output directory")
}
if filepath.Dir(dir) == dir {
break
}
}
file, err := os.OpenFile(filepath.Join(parent, filepath.Base(absolute)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return nil, errors.New("output file must be new and writable")
}
return file, nil
}
// Login binds the exact registered IPv4/IPv6 loopback address before offering
// the browser URL. State, nonce and PKCE are independently generated per attempt.
func (c *Client) Login(ctx context.Context, d Discovery, id, audience, scope, redirect string, output io.Writer) (Tokens, error) {
callback, err := url.Parse(redirect)
if err != nil || callback.Scheme != "http" || callback.User != nil || callback.RawQuery != "" || callback.Fragment != "" || callback.Port() == "" || callback.Port() == "0" || callback.Path == "" {
return Tokens{}, errors.New("redirect-uri must be an exact HTTP loopback URL with a fixed port and path")
}
ip := net.ParseIP(callback.Hostname())
if ip == nil || !ip.IsLoopback() {
return Tokens{}, errors.New("callback must use a literal loopback IP address")
}
listener, err := net.Listen("tcp", callback.Host)
if err != nil {
return Tokens{}, errors.New("could not bind registered callback")
}
defer listener.Close()
state, err := randomValue()
if err != nil {
return Tokens{}, err
}
nonce, err := randomValue()
if err != nil {
return Tokens{}, err
}
verifier, err := randomValue()
if err != nil {
return Tokens{}, err
}
challenge := sha256.Sum256([]byte(verifier))
type callbackResult struct {
code string
err error
}
result := make(chan callbackResult, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if r.Method != http.MethodGet || r.URL.Path != callback.Path || r.Host != callback.Host {
http.Error(w, "Invalid callback", 400)
return
}
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil || len(q["state"]) != 1 || subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 {
http.Error(w, "Invalid callback state", 400)
return
}
var value callbackResult
if q.Get("error") != "" {
value.err = errors.New("login was declined by the identity provider")
} else if len(q["code"]) != 1 || q.Get("code") == "" {
http.Error(w, "Missing authorization code", 400)
return
} else {
value.code = q.Get("code")
}
select {
case result <- value:
fmt.Fprintln(w, "Callback received. Return to the terminal.")
default:
http.Error(w, "Callback already received", 409)
}
})
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 16384}
defer server.Close()
serveErrors := make(chan error, 1)
go func() { serveErrors <- server.Serve(listener) }()
authorize, err := url.Parse(d.Authorization)
if err != nil {
return Tokens{}, errors.New("invalid authorization endpoint")
}
q := authorize.Query()
for name, value := range map[string]string{"client_id": id, "redirect_uri": redirect, "response_type": "code", "scope": scope, "state": state, "nonce": nonce, "code_challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "code_challenge_method": "S256"} {
q.Set(name, value)
}
authorize.RawQuery = q.Encode()
fmt.Fprintf(output, "Open this URL in your browser to authenticate and complete MFA:\n%s\n", authorize.String())
select {
case <-ctx.Done():
return Tokens{}, errors.New("login timed out or was cancelled")
case <-serveErrors:
return Tokens{}, errors.New("callback listener stopped")
case value := <-result:
if value.err != nil {
return Tokens{}, value.err
}
return c.Exchange(ctx, d, url.Values{"grant_type": {"authorization_code"}, "client_id": {id}, "code": {value.code}, "code_verifier": {verifier}, "redirect_uri": {redirect}, "scope": {scope}}, id, "", audience, nonce)
}
}

View file

@ -0,0 +1,221 @@
// Package authclient implements caller-side KeyCape authentication. Credentials
// are delivered to a private file; errors never include response bodies or tokens.
package authclient
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
HTTP *http.Client
Issuer string
}
type Discovery struct {
Issuer string `json:"issuer"`
Authorization string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
JWKS string `json:"jwks_uri"`
}
type Tokens struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
func New(issuer string) (*Client, error) {
u, err := url.Parse(issuer)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("issuer must be an HTTPS URL without credentials, query or fragment")
}
return &Client{Issuer: issuer, HTTP: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
}
func (c *Client) request(ctx context.Context, method, endpoint string, form url.Values, id, secret string, out any) error {
req, err := http.NewRequestWithContext(ctx, method, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return errors.New("invalid provider endpoint")
}
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if secret != "" {
req.SetBasicAuth(url.QueryEscape(id), url.QueryEscape(secret))
}
response, err := c.HTTP.Do(req)
if err != nil {
return errors.New("provider request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("provider rejected request (HTTP %d)", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024+1))
if err != nil || len(body) > 1024*1024 {
return errors.New("invalid provider response size")
}
if err := json.Unmarshal(body, out); err != nil {
return errors.New("invalid provider JSON response")
}
return nil
}
func (c *Client) Discover(ctx context.Context) (Discovery, error) {
var d Discovery
if err := c.request(ctx, http.MethodGet, strings.TrimRight(c.Issuer, "/")+"/.well-known/openid-configuration", nil, "", "", &d); err != nil {
return d, err
}
if d.Issuer != c.Issuer {
return d, errors.New("discovery issuer mismatch")
}
base, _ := url.Parse(c.Issuer)
for _, endpoint := range []string{d.Authorization, d.Token, d.JWKS} {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host != base.Host || u.User != nil || u.Fragment != "" {
return d, errors.New("discovery endpoint must use the issuer HTTPS origin")
}
}
return d, nil
}
// Verify checks RS256 using the discovered issuer's JWKS and exact claim bindings.
func (c *Client) Verify(ctx context.Context, d Discovery, token, audience, nonce string) (map[string]any, error) {
fail := errors.New("token signature or claim validation failed")
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fail
}
decode := base64.RawURLEncoding.DecodeString
header, err := decode(parts[0])
if err != nil {
return nil, fail
}
var h struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Crit []string `json:"crit"`
}
if json.Unmarshal(header, &h) != nil || h.Alg != "RS256" || h.Kid == "" || len(h.Crit) != 0 {
return nil, fail
}
var jwks struct {
Keys []struct{ Kty, Use, Alg, Kid, N, E string } `json:"keys"`
}
if err := c.request(ctx, http.MethodGet, d.JWKS, nil, "", "", &jwks); err != nil {
return nil, err
}
var pub *rsa.PublicKey
for _, k := range jwks.Keys {
if k.Kid != h.Kid {
continue
}
if pub != nil || k.Kty != "RSA" || (k.Alg != "" && k.Alg != "RS256") || (k.Use != "" && k.Use != "sig") {
return nil, fail
}
n, ne := decode(k.N)
e, ee := decode(k.E)
if ne != nil || ee != nil || len(e) == 0 || len(e) > 4 {
return nil, fail
}
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
if pub.N.BitLen() < 2048 || pub.E < 3 || pub.E%2 == 0 {
return nil, fail
}
}
if pub == nil {
return nil, fail
}
sig, err := decode(parts[2])
if err != nil {
return nil, fail
}
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash[:], sig) != nil {
return nil, fail
}
payload, err := decode(parts[1])
if err != nil {
return nil, fail
}
claims := map[string]any{}
if json.Unmarshal(payload, &claims) != nil {
return nil, fail
}
exp, okExp := claims["exp"].(float64)
iat, okIat := claims["iat"].(float64)
now := float64(time.Now().Unix())
if claims["iss"] != c.Issuer || claims["aud"] != audience || !okExp || !okIat || exp <= now || iat > now+30 || iat >= exp {
return nil, fail
}
if sub, ok := claims["sub"].(string); !ok || sub == "" {
return nil, fail
}
if nonce != "" && claims["nonce"] != nonce {
return nil, fail
}
if nbf, exists := claims["nbf"]; exists {
if n, ok := nbf.(float64); !ok || n > now {
return nil, fail
}
}
return claims, nil
}
func (c *Client) Exchange(ctx context.Context, d Discovery, form url.Values, id, secret, audience, nonce string) (Tokens, error) {
var result Tokens
if err := c.request(ctx, http.MethodPost, d.Token, form, id, secret, &result); err != nil {
return Tokens{}, err
}
if !strings.EqualFold(result.TokenType, "Bearer") || result.ExpiresIn <= 0 {
return Tokens{}, errors.New("invalid token response")
}
claims, err := c.Verify(ctx, d, result.AccessToken, audience, "")
if err != nil {
return Tokens{}, err
}
granted, _ := claims["scope"].(string)
for _, s := range strings.Fields(form.Get("scope")) {
if !hasScope(granted, s) {
return Tokens{}, errors.New("required scope absent from token")
}
}
if nonce != "" {
idClaims, err := c.Verify(ctx, d, result.IDToken, id, nonce)
if err != nil {
return Tokens{}, err
}
if idClaims["sub"] != claims["sub"] {
return Tokens{}, errors.New("token subject mismatch")
}
}
return result, nil
}
func hasScope(scopes, want string) bool {
for _, s := range strings.Fields(scopes) {
if s == want {
return true
}
}
return false
}
func randomValue() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

View file

@ -0,0 +1,254 @@
package authclient
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
type users struct{}
func (users) LookupUser(context.Context, string) (*domain.User, error) {
return &domain.User{ID: "user:test", Username: "test"}, nil
}
func (users) LookupGroups(context.Context, string) ([]domain.Group, error) { return nil, nil }
func (users) ValidatePassword(context.Context, string, string) (bool, error) { return true, nil }
func (users) ListUsers(context.Context) ([]domain.User, error) { return nil, nil }
func provider(t *testing.T) (*Client, Discovery, *oidc.TokenHandler) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
server := httptest.NewTLSServer(mux)
t.Cleanup(server.Close)
sessions := oidc.NewSessionStore()
h := &oidc.TokenHandler{Issuer: server.URL, SigningKey: key, TokenLifetime: 15 * time.Minute, Sessions: sessions, Users: users{}, Emitter: telemetry.NoopEmitter{}, ClientConfig: map[string]*domain.Client{
"service:consumer": {ClientID: "service:consumer", ClientType: "confidential", ClientSecret: "special+%: secret", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"approval:read"}, Audience: "approval-engine", ServiceSubject: "service:test", Tenant: "tenant:test"},
"human": {ClientID: "human", AllowedScopes: []string{"openid", "approval:approve"}, Audience: "approval-engine"},
}}
mux.Handle("/token", h)
keys := oidc.NewKeySet()
keys.AddKey("key-1", &key.PublicKey)
mux.Handle("/jwks", oidc.NewJWKSHandler(keys))
d := Discovery{Issuer: server.URL, Authorization: server.URL + "/authorize", Token: server.URL + "/token", JWKS: server.URL + "/jwks"}
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(d) })
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
code := sessions.Create(&oidc.PKCESession{ClientID: q.Get("client_id"), Username: "test", Nonce: q.Get("nonce"), Scopes: strings.Fields(q.Get("scope")), PKCEChallenge: q.Get("code_challenge"), ExpiresAt: time.Now().Add(time.Minute)})
target, _ := url.Parse(q.Get("redirect_uri"))
params := target.Query()
params.Set("state", q.Get("state"))
params.Set("code", code)
target.RawQuery = params.Encode()
http.Redirect(w, r, target.String(), 302)
})
c, err := New(server.URL)
if err != nil {
t.Fatal(err)
}
c.HTTP.Transport = server.Client().Transport
return c, d, h
}
func TestServiceExchangeAndClaimValidation(t *testing.T) {
c, _, h := provider(t)
ctx := context.Background()
d, err := c.Discover(ctx)
if err != nil {
t.Fatal(err)
}
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
token, err := c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Verify(ctx, d, token.AccessToken, "other", ""); err == nil {
t.Fatal("wrong audience accepted")
}
if _, err = c.Verify(ctx, d, token.AccessToken+"tampered", "approval-engine", ""); err == nil {
t.Fatal("tampering accepted")
}
if _, err = c.Exchange(ctx, d, form, "service:consumer", "wrong", "approval-engine", ""); err == nil || strings.Contains(err.Error(), "special") {
t.Fatal("wrong secret not safely rejected")
}
form.Set("scope", "approval:consume")
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
t.Fatal("excess scope accepted")
}
form.Set("scope", "approval:read")
h.TokenLifetime = -time.Minute
if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil {
t.Fatal("expired response accepted")
}
}
type urlWriter struct{ urls chan string }
func (w urlWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(string(p), "\n") {
if strings.HasPrefix(line, "https://") {
w.urls <- line
}
}
return len(p), nil
}
func TestBrowserLoginPKCEAndState(t *testing.T) {
c, d, _ := provider(t)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
redirect := "http://" + listener.Addr().String() + "/callback"
listener.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
urls := make(chan string, 1)
completed := make(chan error, 1)
go func() {
_, err := c.Login(ctx, d, "human", "approval-engine", "openid approval:approve", redirect, urlWriter{urls})
completed <- err
}()
var address string
select {
case address = <-urls:
case err := <-completed:
t.Fatal(err)
case <-ctx.Done():
t.Fatal("no login URL")
}
// A forged callback must not consume the real login attempt.
res, err := http.Get(redirect + "?state=forged&code=forged")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 400 {
t.Fatal("forged state accepted")
}
browser := &http.Client{Transport: c.HTTP.Transport, Timeout: 5 * time.Second}
res, err = browser.Get(address)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
select {
case err := <-completed:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("login did not finish")
}
}
func TestLoginRejectsUnsafeCallbacks(t *testing.T) {
c, d, _ := provider(t)
for _, callback := range []string{"http://example.com:8000/callback", "http://localhost:8000/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:8000/callback?extra=yes"} {
if _, err := c.Login(context.Background(), d, "human", "approval-engine", "openid", callback, io.Discard); err == nil {
t.Fatalf("accepted %s", callback)
}
}
}
func TestOutputProtection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "token.json")
file, err := reserveOutput(path)
if err != nil {
t.Fatalf("reserve %s: %v", path, err)
}
file.Close()
info, _ := os.Stat(path)
if info.Mode().Perm() != 0600 {
t.Fatal("file not private")
}
if _, err = reserveOutput(path); err == nil {
t.Fatal("overwrote existing file")
}
link := filepath.Join(dir, "link")
if err = os.Symlink(path, link); err != nil {
t.Fatal(err)
}
if _, err = reserveOutput(link); err == nil {
t.Fatal("followed output symlink")
}
repo := filepath.Join(dir, "repo")
os.Mkdir(repo, 0700)
os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: elsewhere"), 0600)
if _, err = reserveOutput(filepath.Join(repo, "token")); err == nil {
t.Fatal("allowed token in worktree")
}
}
func TestDiscoveryAndRedirectBoundaries(t *testing.T) {
for _, issuer := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?secret=value"} {
if _, err := New(issuer); err == nil {
t.Fatal("unsafe issuer accepted")
}
}
mux := http.NewServeMux()
server := httptest.NewTLSServer(mux)
defer server.Close()
c, _ := New(server.URL)
c.HTTP.Transport = server.Client().Transport
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":"https://evil.example/a","token_endpoint":"https://evil.example/t","jwks_uri":"https://evil.example/j"}`, server.URL)
})
if _, err := c.Discover(context.Background()); err == nil {
t.Fatal("cross-origin discovery accepted")
}
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://evil.example", 307) })
var out any
if err := c.request(context.Background(), "POST", server.URL+"/redirect", nil, "id", "secret", &out); err == nil {
t.Fatal("followed credential redirect")
}
}
func TestNonceMismatchAndCancelledLogin(t *testing.T) {
c, d, _ := provider(t)
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
token, err := c.Exchange(context.Background(), d, form, "service:consumer", "special+%: secret", "approval-engine", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Verify(context.Background(), d, token.AccessToken, "approval-engine", "required-nonce"); err == nil {
t.Fatal("missing nonce accepted")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
redirect := "http://" + listener.Addr().String() + "/callback"
listener.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = c.Login(ctx, d, "human", "approval-engine", "openid", redirect, io.Discard); err == nil {
t.Fatal("cancelled login succeeded")
}
listener, err = net.Listen("tcp", strings.TrimPrefix(strings.TrimSuffix(redirect, "/callback"), "http://"))
if err != nil {
t.Fatal("listener not released")
}
listener.Close()
}

View file

@ -9,6 +9,7 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
@ -224,6 +225,12 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
Write(w, http.StatusUnauthorized)
return
}
clientID, idErr := url.QueryUnescape(clientID)
clientSecret, secretErr := url.QueryUnescape(clientSecret)
if idErr != nil || secretErr != nil {
profileerrors.InvalidProfileUsage("invalid client authentication encoding", "Authorization").Write(w, http.StatusUnauthorized)
return
}
client, ok := h.ClientConfig[clientID]
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").

View file

@ -4,7 +4,7 @@ type: workplan
title: "Provider capability declarations and bounded service identities"
domain: infotech
repo: key-cape
status: finished
status: blocked
owner: codex
topic_slug: netkingdom
created: "2026-08-23"
@ -85,7 +85,7 @@ custody and platform owners and are not implied by this source registration.
```task
id: KEY-WP-0009-T04
status: done
status: wait
priority: high
state_hub_task_id: "6945c0c0-bbb6-552b-8d62-d806d3fd0018"
```
@ -97,5 +97,8 @@ railiance-platform with revision-independent paths and exact ownership status.
Both declarations pass NetKingdom's canonical validator. The full Go suite,
vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit
Go 1.23 toolchain and writable local cache; the workstation's default Go cache
is read-only and was not used. State Hub handoffs name only non-secret paths and
ownership facts.
is read-only and was not used. The earlier statement that State Hub handoffs were delivered was not supported
by receipt IDs. The 2026-09-05 audit found no matching handoff receipts in the
current Hub response; local implementation and validation were complete, but
notification delivery is unverified. KEY-WP-0014 records the correction and
remaining consumer handoff. Do not use this task as proof of message delivery.

View file

@ -45,3 +45,14 @@ Needs deployment-owned custody for both new secret references and an exact human
callback registration. Deploy the implementation and registrations together,
then prove live JWKS verification and denied excess scopes without logging values.
Local signature proof is not live rollout evidence. See docs/approval-engine-auth-contract.md.
2026-09-05 follow-up: read-only deployment metadata shows the current image is
forgejo.coulomb.social/coulomb/key-cape:main-153258b and only the Qonto service
secret environment reference is present. The two approval clients are not
materialized through deployment environment references. Published a concrete
non-secret admission packet at docs/approval-engine-provisioning-request.yaml.
Custody routing has no exact admitted lane for these two clients. `warden plan`
returned founder_required but matched an unrelated generic database lane; that
mismatch is not authority to provision. Human callback clarification is pending.
No secrets were read or production resources changed.

View file

@ -4,7 +4,7 @@ type: workplan
title: "Review native login and client credential lane handoffs"
domain: infotech
repo: key-cape
status: proposed
status: blocked
owner: codex
topic_slug: native-credential-lane-handoff
created: "2026-09-05"
@ -13,14 +13,14 @@ state_hub_workstream_id: "0d003df3-f7d3-5063-8ca0-e1e33f7df74a"
---
Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed.
The issuer supports client_secret_basic exchange, but has no native exchange or
rotation CLI. Existing machine issuance ownership does not complete this handoff.
The native service exchange and public PKCE login commands are now implemented.
Cross-owner rotation and consumer-specific route cutover remain outstanding.
## Design owner command and custody boundaries
```task
id: KEY-WP-0014-T01
status: todo
status: done
priority: medium
state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f"
```
@ -35,7 +35,7 @@ retiring the proxy until replacement commands have equivalent verification.
```task
id: KEY-WP-0014-T02
status: todo
status: done
priority: low
state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495"
```
@ -43,3 +43,42 @@ state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495"
Ops-warden reports KEY-WP-0009-T04's claimed reply did not arrive. Verify prior
receipts for all four named recipients before claiming successful notification.
No outbound coordination messages were sent during the 2026-09-05 repo review.
## Implement and verify native caller commands
```task
id: KEY-WP-0014-T03
status: done
priority: high
state_hub_task_id: "4e46f474-a15e-594f-ae07-ad37a9667d89"
```
Implemented keycape service-token and login with HTTPS discovery, RS256/JWKS
verification, exact audience bindings, PKCE/state/nonce, private out-of-Git file
delivery and redacted errors. Service Basic authentication now form-decodes
credentials. Integration tests exercise the real token/JWKS handlers and browser
callback, including forged state, tampering, excess scope and unsafe output.
See docs/native-authentication.md for invocation and ownership boundaries.
## Admit rotation and verify consumer handoff
```task
id: KEY-WP-0014-T04
status: wait
priority: high
state_hub_task_id: "e7632c71-9676-5837-a925-7f905e3015c5"
```
Qonto rotation requires admitted custody/provider transaction and rollback
semantics; see the exact review sequence in docs/native-authentication.md.
The current login proxy yields an OpenBao token, so it cannot be replaced by a
JWT-writing command without adapting its consumer contract. Verify that split
with the platform owner and ops-warden before route changes. No route was changed.
2026-09-05 receipt audit: GET /messages/?from_agent=key-cape returned one
2026-03 image-publication message and no KEY-WP-0009 handoff receipts for
NetKingdom, secrets-engine, ops-warden or railiance-platform. Corrected the
unsupported statement in KEY-WP-0009-T04. This proves only that no receipts
are present in the current Hub response, not that no message ever existed.
No replacement outbound messages were sent.