Name: jotsmith. A portmanteau of "JOT" (the colloquial pronunciation of JWT) and the "-smith" suffix that connotes deliberate craft. Binary, module path, and config directory all use this name.
Platform engineers (and the AI agents they delegate to) routinely need to test workload-identity / OIDC federation flows in tools like octo-sts, HashiCorp Vault JWT auth, AWS IAM OIDC, GCP Workload Identity Federation, and any custom system that trusts a JWKS-published issuer. Today that testing requires either:
- Producing a real token from a real source (GitHub Actions runner, Kubernetes pod) and exfiltrating it — slow, leaks production credentials into test contexts, and the token shape is fixed.
- Standing up a full self-hosted IdP — heavyweight for the testing use case.
jotsmith is a single-user CLI that lets a platform engineer stand up a personal OIDC-compliant issuer in Azure in minutes, then mint short-lived JWTs of arbitrary shape on demand. The issuer is publishable at a stable URL backed by Azure Storage; the signing key lives in Azure Key Vault and never leaves it (signing happens inside KV).
- Be a faithful OIDC provider for the discovery + JWKS surface (
/.well-known/openid-configuration+jwks_uri) per OpenID Connect Discovery 1.0. - Let the user mint a JWT with any combination of standard claims (
iss,sub,aud,exp,iat,nbf,jti) and arbitrary custom claims (string or typed). - Sign tokens with a key that lives only in Azure Key Vault. Private key material is never exported.
- Match consumer expectations of providers like GitHub Actions and Kubernetes for the spec surface (
alg: RS256,kidin header, JWKS atjwks_uri, etc.) so existing federation libraries Just Work. - Be agent-friendly: pipe-safe stdout, structured-on-demand output, predictable exit codes, no interactive prompts in non-
setupcommands. - Self-diagnose: a
doctorcommand audits Azure state vs config and offers repair.
- Not a production IdP. No user authentication, no authorization endpoint, no token endpoint, no client registration. Setting
aud/sub/custom claims is on the user; the tool trusts the user. - No support for symmetric (HS*) algorithms — they'd require shared secrets, defeating the JWKS model.
- No multi-user / shared-tenant support. Each user has their own config file, their own Azure SA + KV.
- No automatic rotation. Rotation is a user action.
- No custom domain in v1. Issuer URL is whatever the Azure static-website endpoint resolves to.
- No provisioning of Azure resources. The Storage Account and Key Vault must exist and the running principal must have the required data-plane (and, for SWS enablement, a narrow control-plane) permissions before
setupis run.
See CONTEXT.md for the glossary. Recurring terms in this PRD: Issuer URL, Discovery document, JWKS, kid, Signing key, Claim, Custom claim, Setup, Doctor, Rotate.
+----------------------------+
| jotsmith (local CLI) |
| - DefaultAzureCredential |
| - urfave/cli v3 |
+--------------+-------------+
|
data plane | (Storage Blob + KV Crypto)
|
+----------------------------+
| write blobs | sign(digest) -> signature
v v
+---------------+ +----------------+
| Storage | | Key Vault |
| $web/ | | signing-key |
| .well-known/| | (RSA 2048) |
| openid-... | +----------------+
| jwks.json |
+-------+-------+
|
| HTTPS (public)
v
Consumer (octo-sts, Vault, AWS, ...) fetches
discovery + JWKS to verify tokens minted by the CLI.
The user (or their managed identity) MUST hold the following on the named resources before setup runs successfully:
| Resource | Role needed for | Recommended built-in role |
|---|---|---|
| Storage Account | Enabling static website hosting if not already enabled | Storage Account Contributor (control plane) |
| Storage Account | Reading/writing blobs in $web/.well-known/* |
Storage Blob Data Contributor (data plane) |
| Key Vault (RBAC mode) | Creating, reading, and signing with keys | Key Vault Crypto Officer |
If static website hosting is already enabled by the user, the Storage Account Contributor role is unnecessary and setup will detect & accept that state.
Key Vault must be in Azure RBAC mode, not legacy access-policy mode. doctor checks this and errors clearly if it isn't.
Location: ${XDG_CONFIG_HOME:-$HOME/.config}/jotsmith/config.json. Overridable via --config <path> or JOTSMITH_CONFIG=<path>.
{
"version": 1,
"subscription_id": "00000000-0000-0000-0000-000000000000",
"storage_account": "jotsmithmax",
"key_vault": "jotsmith-max-kv",
"key_name": "signing-key",
"issuer": "https://jotsmithmax.z13.web.core.windows.net",
"jwks_path": ".well-known/jwks.json",
"discovery_path": ".well-known/openid-configuration"
}versionenables future migrations.subscription_idis captured at setup time so the user doesn't have to setAZURE_SUBSCRIPTION_IDfor every invocation.key_nameis the Key Vault key name (a single name; KV manages versions internally).issueris what the tool computed from the SA's primary web endpoint and froze. If the underlying static-website URL ever changes (region migration, etc.),doctorwill detect and offer to re-resolve.jwks_path/discovery_pathare the blob paths inside$web. Configurable to support unusual layouts, but defaulted so users never have to set them.
The schema deliberately does NOT wrap fields under a default: profile key — single-issuer-per-file was a deliberate choice (see ADR-0004). If multi-profile is added later we bump version and migrate.
Global flags accepted on every command:
| Flag | Env | Default | Purpose |
|---|---|---|---|
--config |
JOTSMITH_CONFIG |
XDG path above | Path to config file |
--log-level |
JOTSMITH_LOG_LEVEL |
info |
One of error / warn / info / debug / trace |
--no-color |
NO_COLOR (presence) |
off | Disable color in stderr output |
All non-mint output goes to stderr so that nothing pollutes stdout for piping.
jotsmith setup \
--subscription <id> \
--storage-account <name> \
--key-vault <name> \
[--key-name signing-key] \
[--force]
Behavior, in order:
- Resolve
DefaultAzureCredential. Fail clear if no credential is available. - Verify the subscription is accessible and the named SA + KV are visible (Resource Manager
get). - Storage Account state:
- Check if static website hosting is enabled. If not, enable it (requires Storage Account Contributor; error clearly if missing). Index document = empty (no HTML), no error doc.
- Read the resulting primary web endpoint; this becomes the issuer URL.
- Key Vault state:
- Confirm RBAC mode (refuse to proceed on legacy access-policy vault).
- If a key named
<key-name>exists and is enabled: keep it (unless--force, in which case create a new version and proceed). - If it does not exist: create RSA 2048 key with
keyOps: [sign, verify].
- Compute JWK from the public key. Compute
kid= RFC 7638 thumbprint. - Render and upload (overwriting)
discovery_pathandjwks_pathinto$webwithContent-Type: application/jsonandCache-Control: no-cache(so consumers don't cache during testing). - Write the config file. Print summary to stderr: issuer URL, kid, discovery URL, JWKS URL.
Idempotency: re-running setup with the same args is a no-op for KV state and a refresh-upload for storage state. --force rotates the key (same effect as key rotate).
jotsmith token mint \
--sub <subject> \
[--aud <audience> [--aud ...]] \
[--exp <duration-or-rfc3339>] \
[--iat <rfc3339>] \
[--nbf <rfc3339>] \
[--jti <id>] \
[--claim key=string-value] [...] \
[--claim-json key='<json>'] [...] \
[--claims-file <path>] \
[--verbose]
--subis required.--audis optional. If absent,audis omitted from the payload. If given once,audis a string. If given more than once,audis a JSON array of strings.--expaccepts a Gotime.Duration(15m,1h,24h) interpreted as relative toiat, or an RFC3339 absolute timestamp. Default:15m.--iatdefaults to wall-clocknow().--nbfdefaults toiat.--jtidefaults to a new UUID v4.--claim k=vis repeatable; values are strings.--claim-json k=<json>is repeatable; the value is parsed as JSON (lets you set numbers, booleans, arrays, objects).--claims-filemerges in claims from a JSON file. Precedence: file <--claim-json<--claim. Standard-claim flags always win.issalways comes from config; it cannot be overridden.--verbosecauses the decoded header, payload, and metadata to be pretty-printed to stderr after the JWT is written to stdout.
Stdout: the compact-serialized JWT followed by \n. Exit 0 on success, non-zero on validation or signing failure.
Signing flow (Azure Key Vault):
- Construct header:
{"alg":"RS256","typ":"JWT","kid":"<thumbprint>"}. Canonical JSON, no whitespace. - Construct payload: standard claims + merged custom claims. Canonical JSON, no whitespace.
signing_input = base64url(header) + "." + base64url(payload).digest = SHA-256(signing_input).- Call Key Vault
Sign("RS256", digest)→ signature bytes. token = signing_input + "." + base64url(signature).
No private key material ever touches the CLI process.
jotsmith token verify <jwt> [--aud <expected>] [--sub <expected>]
Live HTTPS round-trip:
- Parse JWT, extract header
kid. - GET
<issuer>/.well-known/openid-configuration. Validateissuerfield equals expected. - GET
jwks_urifrom the discovery doc. Find the JWK with matchingkid. - Reconstruct RSA public key from
n,e. Verify signature. - Verify
issmatches config. Verifyexp > now,nbf <= now,iat <= now + clock_skew. - If
--audprovided, verify the payload'saudcontains/equals it. If--subprovided, verify exact match. - Print
OK+ decoded claims to stderr. Exit 0 on success, 1 on verification failure.
Clock skew tolerance: ±60 seconds.
jotsmith token decode <jwt>
No verification. Splits on ., base64url-decodes header and payload, pretty-prints both as JSON to stdout. Signature bytes are not printed but their byte length is. Useful for inspecting tokens, including ones from other issuers.
jotsmith key rotate [--yes]
- Create a new Key Vault key version (same key name).
- Compute new JWK + thumbprint kid.
- Replace JWKS in storage with a single-entry array of the new key. (Snap-cutover — see ADR-0005.)
- Replace discovery doc only if it changed (it shouldn't unless we add fields).
- Print before/after kid to stderr.
Prompts for confirmation unless --yes. Any in-flight tokens minted with the prior key fail to verify after rotation completes.
jotsmith doctor [--repair] [--json]
Checks, each with PASS / WARN / FAIL:
- Azure credential is resolvable.
- Subscription is accessible.
- Storage Account exists.
- Static website hosting is enabled and primary endpoint matches config
issuer. $web/.well-known/openid-configurationexists, is valid JSON, and itsissuerfield matches config.$web/.well-known/jwks.jsonexists and contains one valid RSA JWK.- Key Vault exists and is in RBAC mode.
- Signing key exists, is enabled, has
signop, and its public key thumbprint matches thekidin the published JWKS. - (Optional) End-to-end: mint a short token and verify it via the live discovery path.
With --repair: any FAIL that the tool knows how to fix (re-upload JWKS, re-upload discovery, re-enable static website) is fixed in place. Errors that require human action (e.g., key vault in legacy mode) are printed but not fixed.
With --json: machine-readable output for agents.
Without flags: pretty stderr report; exit 0 if all PASS or WARN, 1 if any FAIL.
jotsmith config show [--path]
Prints the resolved config file path (with --path) or the config contents pretty-printed to stdout (without).
Prints to stdout the discovery JSON exactly as it would be uploaded by setup / doctor --repair. Doesn't fetch from the network. Useful for diffing against what's actually published.
Prints to stdout the JWKS JSON exactly as it would be uploaded. Computed from the current Key Vault public key.
jotsmith destroy [--yes] [--all]
Deletes:
- The signing key in Key Vault (soft delete; user can purge separately).
- All blobs under
$web/.well-known/.
Does NOT delete the Storage Account or Key Vault themselves. Does NOT delete the config file unless --all is passed. Prompts unless --yes.
jotsmith completion bash|zsh|fish|powershell
Emits the appropriate shell-completion script to stdout. Backed by urfave/cli v3's built-in completion support.
{
"issuer": "https://jotsmithmax.z13.web.core.windows.net",
"jwks_uri": "https://jotsmithmax.z13.web.core.windows.net/.well-known/jwks.json",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid"],
"claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti"]
}Deliberately omitted: authorization_endpoint, token_endpoint, userinfo_endpoint, registration_endpoint. GitHub Actions and many production IdPs omit these for the same reason — there's nothing to point them at. Spec-strict consumers may complain; for the testing use case this matches what real workload-identity issuers publish.
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "<RFC 7638 thumbprint>",
"n": "<base64url RSA modulus>",
"e": "<base64url RSA public exponent>"
}
]
}keys is always an array (length 1 in v1 — see ADR-0003). No x5c / x5t / x5t#S256 — we don't have a cert chain.
- DefaultAzureCredential picks the wrong identity. Tell user which credential resolved (debug log) and which tenant.
- User runs setup against a KV in legacy access-policy mode. Refuse with a clear error pointing at the migration doc.
- User loses Key Vault Crypto Officer role mid-life.
mintfails with a clear error citing the required role on the named KV. - User accidentally disables static website hosting.
doctorflags it,doctor --repairre-enables. - User uploads garbage to
$web/.well-known/by hand.doctordiffs published-vs-computed and offers repair. - Region-migrated storage account changes its
z<n>endpoint.doctordetects mismatch between resolved primary web endpoint and configissuer, refuses to repair silently (would invalidate every consumer's trust policy); requires user-initiatedsetup --force-issuer-rewrite. - Clock skew on mint vs verify. Tool accepts ±60s skew on
nbf/iatchecks during verify.
- Custom domain support and the Front Door / CDN provisioning that goes with it.
- ES256 / PS256 / any non-RS256 algorithm.
- Overlapping-key rotation.
- Built-in claim profiles for GitHub Actions / Kubernetes / etc.
- Provisioning Azure resources (SA, KV, RG).
- Daemon mode / HTTP API. The CLI never serves HTTP itself — Azure does.
- Multi-user / shared-tenant.
- Telemetry, metrics, OpenTelemetry export. (Logs only.)
- Token revocation list. JWTs are self-contained.
- Should
mintwrite the token to a temp file with restrictive perms in addition to stdout, for paranoid users who don't want it in shell history via$(...)? — Probably no; users can do> filethemselves. - Should
setupvalidate the storage account is in a region that supports static websites and that the SKU is GPv2? — Probably yes, with clear error. - Should the tool refuse to set
exp > 24hby default with a--allow-long-livedoverride? — Leaning yes, given this is a test tool; tokens should be short-lived by default.