ec12dfb84f
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
92 lines
2.7 KiB
Go
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.
|
|
`
|