Files
unkinben ec12dfb84f
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add app-token JWT secrets engine
Implement a generic Vault/OpenBao secrets engine that issues short-lived
signed JWTs for self-made services, replacing per-app static bearer
Secrets. Per-app roles set the audience, TTLs, subject allowlist and
custom claims; creds/<role> mints an EdDSA (or RS256) token. Apps
validate offline against the unauthenticated JWKS + OIDC-metadata paths,
so there is no Vault round-trip per request. Signing keys are seal-wrapped
in the barrier and rotate with a configurable JWKS grace window.

Mirrors the other vault-plugin-secrets-* engines: cmd ServeMultiplex,
Makefile with patch|minor|major tags, .woodpecker CI (k8s resources + SA),
dual-flavour nfpm RPM to artifactapi rpm-internal. Tests (-race) cover
issuance+JWKS validation for both algorithms, rotation grace/trim, role
isolation, subject allowlist and the unauthenticated/seal-wrap wiring.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-02 23:42:42 +10:00

92 lines
2.7 KiB
Go

// Package apptoken implements a Vault / OpenBao secrets engine that issues
// short-lived, signed JWT "app tokens" for self-made services. Each per-app
// role mints a token whose audience is the app; services validate tokens
// offline against the engine's unauthenticated JWKS endpoint, so no Vault
// round-trip is needed per request.
//
// The engine replaces per-app static bearer Secrets with a single mechanism:
// any principal allowed to read creds/<role> (for example the agents approle)
// can obtain a token for that app, while signing keys stay inside Vault's
// barrier and only the public JWKS ever leaves it.
package apptoken
import (
"context"
"strings"
"sync"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const (
configStoragePath = "config"
keysetStoragePath = "keyset"
roleStoragePrefix = "role/"
)
type appTokenBackend struct {
*framework.Backend
// keyLock serializes read-modify-write cycles on the signing keyset
// (generation and rotation).
keyLock sync.Mutex
}
// Factory returns a configured app-token secrets backend.
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
b := backend()
if err := b.Setup(ctx, conf); err != nil {
return nil, err
}
return b, nil
}
func backend() *appTokenBackend {
b := &appTokenBackend{}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
BackendType: logical.TypeLogical,
PathsSpecial: &logical.Paths{
// The signing keyset holds private keys; keep it seal-wrapped.
SealWrapStorage: []string{
keysetStoragePath,
},
// JWKS and issuer metadata are public by design: apps fetch them
// to validate tokens offline, with no Vault token.
Unauthenticated: []string{
jwksPath,
openidConfigPath,
},
},
Paths: framework.PathAppend(
[]*framework.Path{
pathConfig(b),
pathConfigKeys(b),
pathConfigKeysRotate(b),
pathRole(b),
pathRolesList(b),
pathCredentials(b),
pathJWKS(b),
pathOpenIDConfig(b),
},
),
}
return b
}
const backendHelp = `
The apptoken secrets engine issues short-lived signed JWTs that self-made
services accept in place of static bearer tokens.
Configure the issuer URL and signing algorithm on "config", define a per-app
role under "roles/<app>", then read "creds/<app>" to mint a token whose
audience is the app. Services validate tokens offline using the public keys at
".well-known/jwks.json" and the metadata at ".well-known/openid-configuration",
both of which are unauthenticated. Rotate signing keys with
"config/keys/rotate"; previous keys stay published in the JWKS during the
configured grace so tokens signed before the rotation keep validating.
`