Skip to content

Latest commit

 

History

History
359 lines (269 loc) · 17.3 KB

File metadata and controls

359 lines (269 loc) · 17.3 KB

PRD: jotsmith

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.

1. Problem & motivation

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).

2. Goals

  • 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, kid in header, JWKS at jwks_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-setup commands.
  • Self-diagnose: a doctor command audits Azure state vs config and offers repair.

3. Non-goals

  • 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 setup is run.

4. Domain language

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.

5. Architecture

5.1 Topology

  +----------------------------+
  | 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.

5.2 Azure resources & required permissions

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.

5.3 Config file schema

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"
}
  • version enables future migrations.
  • subscription_id is captured at setup time so the user doesn't have to set AZURE_SUBSCRIPTION_ID for every invocation.
  • key_name is the Key Vault key name (a single name; KV manages versions internally).
  • issuer is what the tool computed from the SA's primary web endpoint and froze. If the underlying static-website URL ever changes (region migration, etc.), doctor will detect and offer to re-resolve.
  • jwks_path / discovery_path are 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.

6. CLI surface

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.

6.1 jotsmith setup

jotsmith setup \
  --subscription <id> \
  --storage-account <name> \
  --key-vault <name> \
  [--key-name signing-key] \
  [--force]

Behavior, in order:

  1. Resolve DefaultAzureCredential. Fail clear if no credential is available.
  2. Verify the subscription is accessible and the named SA + KV are visible (Resource Manager get).
  3. 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.
  4. 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].
  5. Compute JWK from the public key. Compute kid = RFC 7638 thumbprint.
  6. Render and upload (overwriting) discovery_path and jwks_path into $web with Content-Type: application/json and Cache-Control: no-cache (so consumers don't cache during testing).
  7. 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).

6.2 jotsmith token mint

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]
  • --sub is required.
  • --aud is optional. If absent, aud is omitted from the payload. If given once, aud is a string. If given more than once, aud is a JSON array of strings.
  • --exp accepts a Go time.Duration (15m, 1h, 24h) interpreted as relative to iat, or an RFC3339 absolute timestamp. Default: 15m.
  • --iat defaults to wall-clock now().
  • --nbf defaults to iat.
  • --jti defaults to a new UUID v4.
  • --claim k=v is repeatable; values are strings.
  • --claim-json k=<json> is repeatable; the value is parsed as JSON (lets you set numbers, booleans, arrays, objects).
  • --claims-file merges in claims from a JSON file. Precedence: file < --claim-json < --claim. Standard-claim flags always win.
  • iss always comes from config; it cannot be overridden.
  • --verbose causes 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):

  1. Construct header: {"alg":"RS256","typ":"JWT","kid":"<thumbprint>"}. Canonical JSON, no whitespace.
  2. Construct payload: standard claims + merged custom claims. Canonical JSON, no whitespace.
  3. signing_input = base64url(header) + "." + base64url(payload).
  4. digest = SHA-256(signing_input).
  5. Call Key Vault Sign("RS256", digest) → signature bytes.
  6. token = signing_input + "." + base64url(signature).

No private key material ever touches the CLI process.

6.3 jotsmith token verify

jotsmith token verify <jwt> [--aud <expected>] [--sub <expected>]

Live HTTPS round-trip:

  1. Parse JWT, extract header kid.
  2. GET <issuer>/.well-known/openid-configuration. Validate issuer field equals expected.
  3. GET jwks_uri from the discovery doc. Find the JWK with matching kid.
  4. Reconstruct RSA public key from n,e. Verify signature.
  5. Verify iss matches config. Verify exp > now, nbf <= now, iat <= now + clock_skew.
  6. If --aud provided, verify the payload's aud contains/equals it. If --sub provided, verify exact match.
  7. Print OK + decoded claims to stderr. Exit 0 on success, 1 on verification failure.

Clock skew tolerance: ±60 seconds.

6.4 jotsmith token decode

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.

6.5 jotsmith key rotate

jotsmith key rotate [--yes]
  1. Create a new Key Vault key version (same key name).
  2. Compute new JWK + thumbprint kid.
  3. Replace JWKS in storage with a single-entry array of the new key. (Snap-cutover — see ADR-0005.)
  4. Replace discovery doc only if it changed (it shouldn't unless we add fields).
  5. 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.

6.6 jotsmith doctor

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-configuration exists, is valid JSON, and its issuer field matches config.
  • $web/.well-known/jwks.json exists and contains one valid RSA JWK.
  • Key Vault exists and is in RBAC mode.
  • Signing key exists, is enabled, has sign op, and its public key thumbprint matches the kid in 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.

6.7 jotsmith config show

jotsmith config show [--path]

Prints the resolved config file path (with --path) or the config contents pretty-printed to stdout (without).

6.8 jotsmith discovery show

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.

6.9 jotsmith jwks show

Prints to stdout the JWKS JSON exactly as it would be uploaded. Computed from the current Key Vault public key.

6.10 jotsmith destroy

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.

6.11 jotsmith completion

jotsmith completion bash|zsh|fish|powershell

Emits the appropriate shell-completion script to stdout. Backed by urfave/cli v3's built-in completion support.

7. Published documents — exact shape

Discovery (/.well-known/openid-configuration)

{
  "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.

JWKS (/.well-known/jwks.json)

{
  "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.

8. Failure modes worth designing for

  • 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. mint fails with a clear error citing the required role on the named KV.
  • User accidentally disables static website hosting. doctor flags it, doctor --repair re-enables.
  • User uploads garbage to $web/.well-known/ by hand. doctor diffs published-vs-computed and offers repair.
  • Region-migrated storage account changes its z<n> endpoint. doctor detects mismatch between resolved primary web endpoint and config issuer, refuses to repair silently (would invalidate every consumer's trust policy); requires user-initiated setup --force-issuer-rewrite.
  • Clock skew on mint vs verify. Tool accepts ±60s skew on nbf / iat checks during verify.

9. Out-of-scope for v1

  • 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.

10. Open questions

  1. Should mint write 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 > file themselves.
  2. Should setup validate the storage account is in a region that supports static websites and that the SKU is GPv2? — Probably yes, with clear error.
  3. Should the tool refuse to set exp > 24h by default with a --allow-long-lived override? — Leaning yes, given this is a test tool; tokens should be short-lived by default.

11. ADR index

  • ADR-0001 — Issuer URL is the raw Azure static-website URL.
  • ADR-0002 — Setup configures existing resources only; never provisions.
  • ADR-0003 — RS256 only, JWKS schema designed for future multi-key.
  • ADR-0004 — One issuer per config file.
  • ADR-0005 — Rotation is snap-cutover, not overlapping.