Add native verified login and service-token commands
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
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:
parent
66df5fcf07
commit
b989de4e90
12 changed files with 928 additions and 14 deletions
|
|
@ -267,3 +267,12 @@ Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under
|
||||||
domain `netkingdom`, repo slug `key-cape`.
|
domain `netkingdom`, repo slug `key-cape`.
|
||||||
|
|
||||||
See `CLAUDE.md` for agent session protocol and workplan conventions.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,12 @@
|
||||||
| workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
| 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-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-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-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-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-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-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 | 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-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
| task | KEY-WP-0001-T02 | 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-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-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-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-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-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 |
|
| 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-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-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-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-T01 | done | — | 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-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 |
|
| intake | KEY-IN-0001 | closed | — | intakes/intakes.md |
|
||||||
|
|
|
||||||
45
docs/approval-engine-provisioning-request.yaml
Normal file
45
docs/approval-engine-provisioning-request.yaml
Normal 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.
|
||||||
87
docs/native-authentication.md
Normal file
87
docs/native-authentication.md
Normal 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.
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"keycape/internal/adapters/authelia"
|
"keycape/internal/adapters/authelia"
|
||||||
"keycape/internal/adapters/lldap"
|
"keycape/internal/adapters/lldap"
|
||||||
"keycape/internal/adapters/privacyidea"
|
"keycape/internal/adapters/privacyidea"
|
||||||
|
"keycape/internal/authclient"
|
||||||
"keycape/internal/config"
|
"keycape/internal/config"
|
||||||
"keycape/internal/domain"
|
"keycape/internal/domain"
|
||||||
servererrors "keycape/internal/server/errors"
|
servererrors "keycape/internal/server/errors"
|
||||||
|
|
@ -31,6 +32,14 @@ import (
|
||||||
const version = "0.1.0"
|
const version = "0.1.0"
|
||||||
|
|
||||||
func main() {
|
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()
|
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
227
src/internal/authclient/cli.go
Normal file
227
src/internal/authclient/cli.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/internal/authclient/client.go
Normal file
221
src/internal/authclient/client.go
Normal 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
|
||||||
|
}
|
||||||
254
src/internal/authclient/client_test.go
Normal file
254
src/internal/authclient/client_test.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -224,6 +225,12 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
||||||
Write(w, http.StatusUnauthorized)
|
Write(w, http.StatusUnauthorized)
|
||||||
return
|
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]
|
client, ok := h.ClientConfig[clientID]
|
||||||
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
|
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
|
||||||
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
|
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Provider capability declarations and bounded service identities"
|
title: "Provider capability declarations and bounded service identities"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: key-cape
|
repo: key-cape
|
||||||
status: finished
|
status: blocked
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-08-23"
|
created: "2026-08-23"
|
||||||
|
|
@ -85,7 +85,7 @@ custody and platform owners and are not implied by this source registration.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0009-T04
|
id: KEY-WP-0009-T04
|
||||||
status: done
|
status: wait
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6945c0c0-bbb6-552b-8d62-d806d3fd0018"
|
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,
|
Both declarations pass NetKingdom's canonical validator. The full Go suite,
|
||||||
vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit
|
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
|
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
|
is read-only and was not used. The earlier statement that State Hub handoffs were delivered was not supported
|
||||||
ownership facts.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
callback registration. Deploy the implementation and registrations together,
|
||||||
then prove live JWKS verification and denied excess scopes without logging values.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Review native login and client credential lane handoffs"
|
title: "Review native login and client credential lane handoffs"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: key-cape
|
repo: key-cape
|
||||||
status: proposed
|
status: blocked
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: native-credential-lane-handoff
|
topic_slug: native-credential-lane-handoff
|
||||||
created: "2026-09-05"
|
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.
|
Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed.
|
||||||
The issuer supports client_secret_basic exchange, but has no native exchange or
|
The native service exchange and public PKCE login commands are now implemented.
|
||||||
rotation CLI. Existing machine issuance ownership does not complete this handoff.
|
Cross-owner rotation and consumer-specific route cutover remain outstanding.
|
||||||
|
|
||||||
## Design owner command and custody boundaries
|
## Design owner command and custody boundaries
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0014-T01
|
id: KEY-WP-0014-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f"
|
state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f"
|
||||||
```
|
```
|
||||||
|
|
@ -35,7 +35,7 @@ retiring the proxy until replacement commands have equivalent verification.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0014-T02
|
id: KEY-WP-0014-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495"
|
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
|
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.
|
receipts for all four named recipients before claiming successful notification.
|
||||||
No outbound coordination messages were sent during the 2026-09-05 repo review.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue