From 2fc51d7b02041dffd7e2e12459626339086cac3f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:27:04 -0400 Subject: [PATCH 1/9] =?UTF-8?q?docs(auth):=20design=20auth#41=20=E2=80=94?= =?UTF-8?q?=20cross-service=20asymmetric=20auth=20via=20reuse=20(no=20besp?= =?UTF-8?q?oke=20IDP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Determination: #41 already supported by engine auth.m2m (ES256+JWKS+trusted-keys) + workflow-plugin-sso (step.sso_validate_token/refresh/exchange) + the AuthProviderDescriptor provider pattern. Deliver a workflow-scenarios demo (App A issues ES256 / App B verifies cross-service; external-IDP OIDC leg) + a use-case->combination matrix doc, then close #41. Zero bespoke auth code. Cites ADR-0002. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...02-cross-service-asymmetric-auth-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md new file mode 100644 index 0000000..898f6ce --- /dev/null +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md @@ -0,0 +1,91 @@ +# auth#41 — Cross-Service Asymmetric Auth via Reuse (Design, 2026-06-02) + +Issue: GoCodeAlone/workflow-plugin-auth#41 (Phase II IDP). **Determination: already supported by existing components — demonstrate + document the composition, do NOT build a bespoke IDP.** ADR-0002 deferred a plugin IDP "until a consumer needs cross-service asymmetric verification"; investigation shows the engine + provider plugins already cover it. + +## §G — Goal + +Prove and document that Workflow already supports **cross-service asymmetric (ES256) JWT auth** — multiple Workflow apps authenticating to each other (M2M, no browser) AND human login via external IDPs (Ory/Auth0/Entra/Okta) — using **existing** components and the **provider pattern**. Close #41 with a demonstrated, documented reuse story. **Zero bespoke auth modules/steps.** + +## §C — Constraints / Global Design Guidance + +Guidance: none durable file; from SPEC.md C1-C5 + the 2026-05-27 auth-provider-architecture design (the shipped provider pattern) + user Q&A. + +| guidance | response | +|---|---| +| Reuse, don't reimplement; use existing provider solutions (user directive) | Compose engine `auth.m2m` + `workflow-plugin-sso` + provider plugins. No new auth machinery. | +| Provider pattern is the home for IDP/SSO (`AuthProviderDescriptor`, 2026-05-27 arch) | External IDPs wired via provider descriptors (okta/auth0/entra/ory-*/scalekit); generic OIDC via `sso.oidc`. | +| Plugin stays a stateless primitive library (SPEC C1) | Bootstrap was the one bespoke primitive; cross-service/IDP = reuse. | +| Demo proves it (provider-arch R7) | A workflow-scenarios stack demonstrates the composition end-to-end. | + +## §I — Capability map (#41 need → existing component) + +| Need | Reused component | Verified | +|---|---|---| +| App-to-app async **issue** (ES256 asymmetric) + key publication | engine `auth.m2m` (`GenerateECDSAKey`, `/oauth/token` client_credentials/jwt-bearer, `/oauth/jwks`) | `module/auth_m2m.go` | +| Cross-service **verification** (M2M, in-pipeline) | `auth.m2m` `trustedKeys[]` (issuer+publicKeyPEM) + `step.auth_validate` | `auth_m2m.go:236`, `pipeline_step_auth_validate.go` | +| External-IDP **OIDC verification** (asymmetric, JWKS discovery) | `workflow-plugin-sso` `sso.oidc` + `step.sso_validate_token` (go-oidc `IDTokenVerifier`, `issuer`/`jwksUri`) | `workflow-plugin-sso/internal/{oidc.go,step_validate_token.go}` | +| **Refresh tokens** | `step.sso_refresh_token` | `workflow-plugin-sso/internal/step_refresh_token.go` | +| **Token exchange / userinfo** | `step.sso_token_exchange` / `step.sso_userinfo` | sso | +| External-IDP wiring (no bespoke vendor code) | provider pattern: `AuthProviderDescriptor` + `step.auth_provider_catalog` + okta/auth0/entra/ory-*/scalekit plugins | `internal/step_provider_catalog.go` | +| Human/browser login plane | OIDC login (engine `step.oidc_auth_url`/`step.oidc_callback` or plugin `step.auth_oauth_*`) → verify via `step.sso_validate_token` | engine + plugin | + +### Two planes (answers "does a browser user factor in?") +- **M2M / app-to-app** — no browser. `auth.m2m` issues ES256 (client_credentials); the consumer app verifies with `step.auth_validate` against an `auth.m2m` holding the issuer's public key as a `trustedKeys` entry. Cross-service asymmetric, fully headless. +- **Human / browser** — OIDC login against an external IDP (provider pattern); the resulting ID token is verified by `step.sso_validate_token` (`sso.oidc`, JWKS). Refresh via `step.sso_refresh_token`. + +Both are existing; the plugin adds neither. + +## §V — What is NOT built (and why) + +- ⊥ `auth.idp` module — engine `auth.m2m` already is the asymmetric issuer + JWKS server. +- ⊥ `step.auth_jwks_serve` — `auth.m2m` `/oauth/jwks` already serves RFC-7517 JWKS for its ES256 key. +- ⊥ asymmetric `step.auth_jwt_issue` — `auth.m2m` is the canonical ES256 issuer; the plugin's HS256 `auth_jwt_issue` stays the symmetric same-app session primitive (v0.3.0). +- ⊥ `step.auth_refresh_token_*` — `step.sso_refresh_token` exists. +Building any of these = duplicating the engine/provider plugins (explicitly rejected by user direction + provider-arch option A/B). + +## Deliverables (scope) + +1. **workflow-scenarios demo** — `NN-cross-service-asymmetric-auth` admin stack proving the composition end-to-end: + - **App A (issuer)**: `auth.m2m` ES256, exposes `/oauth/token` (client_credentials) + `/oauth/jwks`. + - **App B (verifier)**: pipeline route gated by `step.auth_validate` against App B's `auth.m2m` configured with App A's public key as a `trustedKeys` entry — accepts App A's ES256 token (cross-service), rejects tampered / wrong-issuer / expired. + - **External-IDP / OIDC leg**: `sso.oidc` + `step.sso_validate_token` verifying an OIDC ID token from a mock OIDC issuer (issuer+jwksUri); `step.sso_refresh_token` refresh. + - **Browser leg (DoD)**: a small "verification console" UI — fetch/obtain a token, POST to App B's verify route, render verified claims vs rejection — driven by **playwright-cli exploratory QA** + a committed **Playwright** test. + - curl smoke (deterministic, self-isolating) + Playwright + playwright-cli QA (EXPLORATORY.md + screenshots). Register in `scenarios.json`. +2. **Plugin use-case documentation** (workflow-plugin-auth README + SPEC §X) — a **use-case → step/module-combination matrix**: which combination covers (a) same-app HS256 session, (b) app-to-app M2M asymmetric, (c) external-IDP human OIDC login, (d) refresh, (e) enterprise SSO/SCIM — pointing at `auth.m2m`/`sso`/provider plugins. This is the "document what use cases are covered and by what combinations" deliverable. +3. **Provider descriptor** — add an `oauth2_oidc` / `m2m` `AuthProviderDescriptor` to the catalog only if absent (descriptor-only, no runtime auth code). +4. **Close #41** with the demonstrated evidence. + +## Security Review +- Asymmetric verification means verifier apps hold only PUBLIC keys (no shared secret) — the whole point; least-privilege across services. +- `trustedKeys` entries are public keys (non-secret); issuer private key stays in the issuer app (env/secrets). JWKS exposes only the public key. +- `step.auth_validate` / `step.sso_validate_token` reject on signature/issuer/audience/expiry mismatch (verified server-side). Scenario asserts tampered + wrong-issuer rejection. +- No new attack surface — composition of existing, contract-tested steps. + +## Infrastructure Impact +- No plugin release strictly required (docs + descriptor only). If a provider descriptor is added → workflow-plugin-auth patch release + registry manifest bump. +- Scenario: isolated docker-compose (2 engine apps + mock OIDC issuer); own port range; no cloud. +- Rollback: revert scenario PR (remove from scenarios.json); revert docs PR; no runtime consumers affected. + +## Multi-Component Validation +Real boundary: **App A (issuer) ↔ App B (verifier)** across process boundaries via ES256 + JWKS, plus **app ↔ external OIDC issuer**. curl smoke proves cross-service accept/reject deterministically; Playwright + playwright-cli prove the browser verification console; both self-isolate. + +## Assumptions +1. `auth.m2m` `trustedKeys[]` config + ES256 issue + `/oauth/jwks` work as read in source (verify at runtime in the scenario). +2. `sso.oidc` verifies via `issuer`+`jwksUri` against a mock OIDC issuer that serves discovery/JWKS (scenario 84 precedent uses sso.oidc against a real issuer). *If the mock-OIDC-with-discovery proves heavy:* the external-IDP leg falls back to verifying App A's `auth.m2m` token via `sso.oidc` `jwksUri=App A /oauth/jwks` OR documents the OIDC leg with the M2M leg as the proven core. +3. A browser "verification console" satisfies the browser-plane DoD without a full external-IDP login UI (the human-OIDC-login UI is documented; the console demonstrates verification visibly). + +## Rollback +| Change | Class | Rollback | +|---|---|---| +| scenario (docker-compose stack) | isolated test asset | revert PR; remove scenarios.json entry | +| provider descriptor (if added) | manifest data + patch release | revert; don't advance tag | +| docs (README/SPEC) | docs | revert | + +## Non-goals / follow-ups +- Refresh-token issuance by `auth.m2m` (it consumes/validates; OIDC providers issue refresh) — not needed; `step.sso_refresh_token` covers the consumer path. +- gocodealone-multisite adoption — tracked separately (#54). + +## Top doubts (self-challenge) +- D1: app-to-app verify path — `auth.m2m` trusted-keys (chosen, direct) vs `sso.oidc` (needs discovery/jwksUri). Resolved: trusted-keys for M2M leg; validated at runtime. +- D2: mock OIDC issuer for the external-IDP leg may be heavy → Assumption 2 fallback keeps the proven core (M2M) intact. +- D3: "zero new code" under-delivers #41? No — the value is the *proven + documented* composition + correct closure; building would duplicate the engine/providers (user-rejected). From fa8fddeeb542293a363ff3b4ae9c327dcdf5123e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:36:28 -0400 Subject: [PATCH 2/9] =?UTF-8?q?docs(auth):=20design=20rev=202=20=E2=80=94?= =?UTF-8?q?=20resolve=20adversarial=20cycle-1=20(3C)=20+=20ADR-0003?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-1 wrongly claimed auth.m2m trustedKeys+step.auth_validate verifies cross-service (Authenticate uses only own key; sso.oidc needs discovery). Real gap = jwksUri-only verify mode in sso.oidc (NewRemoteKeySet) → App B verifies App A's ES256 token from App A's /oauth/jwks. Fixes client_credentials-secret terminology, refresh scope, aud validation, drops scenario-84 false precedent. ADR-0003 records the sso-jwksUri choice over auth.m2m-multikey / bespoke IDP. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...3-cross-service-verify-via-sso-jwks-uri.md | 22 ++++ ...02-cross-service-asymmetric-auth-design.md | 122 ++++++++++-------- 2 files changed, 87 insertions(+), 57 deletions(-) create mode 100644 decisions/0003-cross-service-verify-via-sso-jwks-uri.md diff --git a/decisions/0003-cross-service-verify-via-sso-jwks-uri.md b/decisions/0003-cross-service-verify-via-sso-jwks-uri.md new file mode 100644 index 0000000..d92b80d --- /dev/null +++ b/decisions/0003-cross-service-verify-via-sso-jwks-uri.md @@ -0,0 +1,22 @@ +# 0003. Cross-service asymmetric verify via sso.oidc JWKS-URI mode + +**Status:** Accepted +**Date:** 2026-06-02 +**Decision-makers:** Jon Langevin (codingsloth@pm.me), autodev pipeline +**Related:** `docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md` (rev 2), issue #41, ADR-0002, adversarial cycle-1 (F1/F2/F6) + +## Context + +#41 asks for cross-service asymmetric (ES256) JWT auth between Workflow apps + external IDPs, using the provider pattern (no bespoke IDP). Investigation: engine `auth.m2m` already issues ES256 + serves `/oauth/jwks`. But the verify side was the gap — adversarial review proved `auth.m2m.Authenticate()` verifies only with the module's OWN key (`trustedKeys` feed only the jwt-bearer grant), and `workflow-plugin-sso`'s `sso.oidc` only verifies via full OIDC discovery (`oidc.NewProvider`), which `auth.m2m` does not serve. So no existing path lets App B verify App A's ES256 token from App A's published JWKS. + +## Decision + +Add a **`jwksUri`-only verifier mode** to the existing `workflow-plugin-sso` `sso.oidc` provider: when `jwksUri` is configured, build the verifier with go-oidc `NewRemoteKeySet` + `NewVerifier` (issuer + audience pinned), skipping discovery. App B then verifies App A's ES256 token via `step.sso_validate_token` against App A's `/oauth/jwks`. *Alternatives rejected:* (a) add multi-key verification to engine `auth.m2m.Authenticate` — touches engine core for a provider concern, fights the provider pattern; (b) jwt-bearer token exchange (App B exchanges App A's token for a local one) — a token-exchange, not inline verification, and heavier; (c) a bespoke `auth.idp`/JWKS in workflow-plugin-auth — duplicates the engine + the SSO provider (ADR-0002, user-rejected). + +## Consequences + +- (+) Genuine asymmetric cross-service verification: verifier holds only the public JWKS, no shared secret, no discovery dependency; lazy key fetch avoids boot-order coupling. +- (+) Enhances the existing OIDC provider plugin (provider-pattern-aligned); ~25 LOC + test; engine core + workflow-plugin-auth runtime untouched. +- (+) Also serves real external IDPs that lack/limit OIDC discovery (some Entra B2C configs). +- (−) jwksUri mode is verify-only (no token endpoint) → refresh/exchange still require the discovery path (documented). +- (−) Adds a config field + a second init path to `sso.oidc` (small maintenance surface). diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md index 898f6ce..8e08ffc 100644 --- a/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md @@ -1,91 +1,99 @@ -# auth#41 — Cross-Service Asymmetric Auth via Reuse (Design, 2026-06-02) +# auth#41 — Cross-Service Asymmetric Auth (Design, 2026-06-02, rev 2) -Issue: GoCodeAlone/workflow-plugin-auth#41 (Phase II IDP). **Determination: already supported by existing components — demonstrate + document the composition, do NOT build a bespoke IDP.** ADR-0002 deferred a plugin IDP "until a consumer needs cross-service asymmetric verification"; investigation shows the engine + provider plugins already cover it. +Issue: GoCodeAlone/workflow-plugin-auth#41 (Phase II IDP). **Determination: reuse the engine + provider plugins; the only genuine gap is a small `jwksUri`-only verify mode in the existing `workflow-plugin-sso` provider — NOT a bespoke IDP.** Then demonstrate + document the composition and close #41. + +> **rev 2 (adversarial cycle-1, 3 Critical):** cycle-1 design wrongly claimed `auth.m2m` `trustedKeys[]` + `step.auth_validate` verifies a cross-service token — **FALSE** (`parseTokenClaims`/`Authenticate` uses only the module's OWN `m.publicKey`; `trustedKeys` feed only the jwt-bearer *grant*, `auth_m2m.go:852-855`). And `sso.oidc` requires full OIDC discovery (`oidc.NewProvider`, `oidc.go:98`) — it ignores `jwksUri`, and `auth.m2m` serves no `/.well-known/openid-configuration`, so neither verify path worked. **Resolution:** the real, minimal, provider-pattern-aligned gap is a `jwksUri`-only verifier mode in `sso.oidc` (`oidc.NewRemoteKeySet`+`NewVerifier`). With it, App B verifies App A's ES256 token from App A's published `/oauth/jwks` — genuinely asymmetric, cross-process, no shared secret, no discovery, no mock-OIDC. Also fixes client_credentials-vs-jwt-bearer terminology, refresh scope, aud validation, and drops the invalid scenario-84 "precedent". ## §G — Goal -Prove and document that Workflow already supports **cross-service asymmetric (ES256) JWT auth** — multiple Workflow apps authenticating to each other (M2M, no browser) AND human login via external IDPs (Ory/Auth0/Entra/Okta) — using **existing** components and the **provider pattern**. Close #41 with a demonstrated, documented reuse story. **Zero bespoke auth modules/steps.** +Make Workflow **fully support cross-service asymmetric (ES256) JWT auth** — multiple Workflow apps verifying each other's tokens (M2M, no browser) and human login via external IDPs — by **reusing** existing components + one small enhancement to the SSO provider, then proving and documenting it. Close #41. ## §C — Constraints / Global Design Guidance -Guidance: none durable file; from SPEC.md C1-C5 + the 2026-05-27 auth-provider-architecture design (the shipped provider pattern) + user Q&A. - | guidance | response | |---|---| -| Reuse, don't reimplement; use existing provider solutions (user directive) | Compose engine `auth.m2m` + `workflow-plugin-sso` + provider plugins. No new auth machinery. | -| Provider pattern is the home for IDP/SSO (`AuthProviderDescriptor`, 2026-05-27 arch) | External IDPs wired via provider descriptors (okta/auth0/entra/ory-*/scalekit); generic OIDC via `sso.oidc`. | -| Plugin stays a stateless primitive library (SPEC C1) | Bootstrap was the one bespoke primitive; cross-service/IDP = reuse. | -| Demo proves it (provider-arch R7) | A workflow-scenarios stack demonstrates the composition end-to-end. | +| Reuse / use existing provider solutions; don't build our own auth (user) | One ~25-LOC enhancement to the *existing* `sso.oidc` provider (JWKS-URI verify); everything else composed from engine `auth.m2m` + `sso` steps + provider plugins. No new module, no new step type. | +| Provider pattern is the IDP home (2026-05-27 arch) | The fix lives in `workflow-plugin-sso` (the OIDC provider plugin) + uses `AuthProviderDescriptor`. | +| Plugin stays a stateless primitive lib (SPEC C1); bootstrap was the one bespoke primitive | No change to workflow-plugin-auth runtime; it only gains docs (+ provider descriptor if missing). | +| Demo proves it (provider-arch R7) | workflow-scenarios stack demonstrates genuine cross-process asymmetric verify. | -## §I — Capability map (#41 need → existing component) +## §I — Capability map (#41 need → component) -| Need | Reused component | Verified | +| Need | Component | State | |---|---|---| -| App-to-app async **issue** (ES256 asymmetric) + key publication | engine `auth.m2m` (`GenerateECDSAKey`, `/oauth/token` client_credentials/jwt-bearer, `/oauth/jwks`) | `module/auth_m2m.go` | -| Cross-service **verification** (M2M, in-pipeline) | `auth.m2m` `trustedKeys[]` (issuer+publicKeyPEM) + `step.auth_validate` | `auth_m2m.go:236`, `pipeline_step_auth_validate.go` | -| External-IDP **OIDC verification** (asymmetric, JWKS discovery) | `workflow-plugin-sso` `sso.oidc` + `step.sso_validate_token` (go-oidc `IDTokenVerifier`, `issuer`/`jwksUri`) | `workflow-plugin-sso/internal/{oidc.go,step_validate_token.go}` | -| **Refresh tokens** | `step.sso_refresh_token` | `workflow-plugin-sso/internal/step_refresh_token.go` | -| **Token exchange / userinfo** | `step.sso_token_exchange` / `step.sso_userinfo` | sso | -| External-IDP wiring (no bespoke vendor code) | provider pattern: `AuthProviderDescriptor` + `step.auth_provider_catalog` + okta/auth0/entra/ory-*/scalekit plugins | `internal/step_provider_catalog.go` | -| Human/browser login plane | OIDC login (engine `step.oidc_auth_url`/`step.oidc_callback` or plugin `step.auth_oauth_*`) → verify via `step.sso_validate_token` | engine + plugin | +| App-to-app **issue** (ES256) + JWKS publication | engine `auth.m2m` (`algorithm: ES256` + `privateKey`/generated; `/oauth/token`; `/oauth/jwks` RFC-7517) | exists | +| Cross-service **verification** from published JWKS (asymmetric, no discovery) | **`workflow-plugin-sso` `sso.oidc` NEW `jwksUri` mode** + `step.sso_validate_token` | **the gap — this design builds it** | +| External-IDP **OIDC verification** (discovery) | `sso.oidc` (discovery path, unchanged) + `step.sso_validate_token` | exists | +| **Refresh** | OIDC plane: `step.sso_refresh_token` (discovery providers). M2M plane: re-issue via `auth.m2m` `/oauth/token` (no refresh token for client_credentials — by design). | exists / documented | +| External-IDP wiring (no bespoke vendor code) | provider pattern: `AuthProviderDescriptor` + okta/auth0/entra/ory-*/scalekit | exists | +| Human/browser login | OIDC login (`step.oidc_auth_url`/`callback` or `step.auth_oauth_*`) → verify via `step.sso_validate_token` | exists (documented) | -### Two planes (answers "does a browser user factor in?") -- **M2M / app-to-app** — no browser. `auth.m2m` issues ES256 (client_credentials); the consumer app verifies with `step.auth_validate` against an `auth.m2m` holding the issuer's public key as a `trustedKeys` entry. Cross-service asymmetric, fully headless. -- **Human / browser** — OIDC login against an external IDP (provider pattern); the resulting ID token is verified by `step.sso_validate_token` (`sso.oidc`, JWKS). Refresh via `step.sso_refresh_token`. +### The gap (only new code): `sso.oidc` JWKS-URI verify mode +`InitProvider` (`workflow-plugin-sso/internal/oidc.go:91`) only does `oidc.NewProvider(ctx, issuer)` (discovery). Add: when a provider config sets `jwksUri`, build the verifier via `oidc.NewRemoteKeySet(ctx, jwksUri)` + `oidc.NewVerifier(issuer, keySet, &oidc.Config{ClientID: audience, SkipClientIDCheck: audience==""})` — **no discovery required**. `ProviderConfig` gains `JWKSURI` (parsed `getString(raw,"jwksUri")`). In this mode `OAuthCfg.Endpoint` is empty (verify-only; refresh/exchange need the discovery path — documented). go-oidc v3.12.0 has both APIs; `internal/oidc_test.go` already has a `mockOIDCServer` for tests. ~25 LOC + test. Patch release of workflow-plugin-sso. -Both are existing; the plugin adds neither. +### Two planes (answers "does a browser user factor in?") +- **M2M / app-to-app** — no browser. App A `auth.m2m` (ES256) mints its own token (the client_credentials secret is App-A-LOCAL — it authenticates the caller to App A's own issuer; it is NOT shared with App B). Cross-service trust is **purely the public JWKS**: App B holds no secret, only fetches App A's `/oauth/jwks`. App B verifies via `step.sso_validate_token` (jwksUri mode). +- **Human / browser** — OIDC login at an external IDP (provider pattern); ID token verified by `step.sso_validate_token` (discovery mode); refresh via `step.sso_refresh_token`. ## §V — What is NOT built (and why) - -- ⊥ `auth.idp` module — engine `auth.m2m` already is the asymmetric issuer + JWKS server. -- ⊥ `step.auth_jwks_serve` — `auth.m2m` `/oauth/jwks` already serves RFC-7517 JWKS for its ES256 key. -- ⊥ asymmetric `step.auth_jwt_issue` — `auth.m2m` is the canonical ES256 issuer; the plugin's HS256 `auth_jwt_issue` stays the symmetric same-app session primitive (v0.3.0). -- ⊥ `step.auth_refresh_token_*` — `step.sso_refresh_token` exists. -Building any of these = duplicating the engine/provider plugins (explicitly rejected by user direction + provider-arch option A/B). +- ⊥ `auth.idp` module / `step.auth_jwks_serve` / asymmetric `step.auth_jwt_issue` — `auth.m2m` already issues ES256 + serves JWKS. +- ⊥ refresh-token issuance in `auth.m2m` — client_credentials has no refresh by design; OIDC providers issue refresh (`step.sso_refresh_token`). +- ⊥ multi-key verify in `auth.m2m.Authenticate` — out of scope; the SSO JWKS-URI verifier is the cleaner, provider-pattern-aligned path (engine core untouched). ## Deliverables (scope) - -1. **workflow-scenarios demo** — `NN-cross-service-asymmetric-auth` admin stack proving the composition end-to-end: - - **App A (issuer)**: `auth.m2m` ES256, exposes `/oauth/token` (client_credentials) + `/oauth/jwks`. - - **App B (verifier)**: pipeline route gated by `step.auth_validate` against App B's `auth.m2m` configured with App A's public key as a `trustedKeys` entry — accepts App A's ES256 token (cross-service), rejects tampered / wrong-issuer / expired. - - **External-IDP / OIDC leg**: `sso.oidc` + `step.sso_validate_token` verifying an OIDC ID token from a mock OIDC issuer (issuer+jwksUri); `step.sso_refresh_token` refresh. - - **Browser leg (DoD)**: a small "verification console" UI — fetch/obtain a token, POST to App B's verify route, render verified claims vs rejection — driven by **playwright-cli exploratory QA** + a committed **Playwright** test. +1. **workflow-plugin-sso** — `jwksUri` verify mode in `sso.oidc` (`InitProvider` + `ProviderConfig.JWKSURI` + parse) + unit test (verify a token against a remote JWKS via the existing `mockOIDCServer`; reject wrong-key + aud-mismatch) + README + plugin.json/version. Patch release. +2. **workflow-scenarios** — `NN-cross-service-asymmetric-auth` stack (docker-compose, ≥2 engine processes): + - **App A (issuer)**: `auth.m2m` ES256; `/oauth/token` (client_credentials, App-A-local client) + `/oauth/jwks`. Mints a token (aud=`app-b`). + - **App B (verifier)**: `sso.oidc` `jwksUri: http://app-a:8080/oauth/jwks`, `issuer`+`audience: app-b`; route gated by `step.sso_validate_token`. Asserts: App A's token **accepted** (verified from App A's PUBLIC key only); token signed by a **different** key **rejected** (proves real asymmetric, not same-key); **aud-mismatch rejected**; expired rejected. + - **Browser leg (DoD)**: a small "verification console" UI on App B — fetch App A's token, POST to App B's verify route, render verified claims vs rejection — driven by **playwright-cli exploratory QA** + a committed **Playwright** test. - curl smoke (deterministic, self-isolating) + Playwright + playwright-cli QA (EXPLORATORY.md + screenshots). Register in `scenarios.json`. -2. **Plugin use-case documentation** (workflow-plugin-auth README + SPEC §X) — a **use-case → step/module-combination matrix**: which combination covers (a) same-app HS256 session, (b) app-to-app M2M asymmetric, (c) external-IDP human OIDC login, (d) refresh, (e) enterprise SSO/SCIM — pointing at `auth.m2m`/`sso`/provider plugins. This is the "document what use cases are covered and by what combinations" deliverable. -3. **Provider descriptor** — add an `oauth2_oidc` / `m2m` `AuthProviderDescriptor` to the catalog only if absent (descriptor-only, no runtime auth code). -4. **Close #41** with the demonstrated evidence. +3. **workflow-plugin-auth** — **use-case → step/module-combination matrix** in README + SPEC §X (same-app HS256 session · app-to-app M2M asymmetric · external-IDP human OIDC · refresh · enterprise SSO/SCIM → which `auth.m2m`/`sso`/provider combination). Add an `oauth2_oidc` `AuthProviderDescriptor` only if the catalog lacks one. Close #41. ## Security Review -- Asymmetric verification means verifier apps hold only PUBLIC keys (no shared secret) — the whole point; least-privilege across services. -- `trustedKeys` entries are public keys (non-secret); issuer private key stays in the issuer app (env/secrets). JWKS exposes only the public key. -- `step.auth_validate` / `step.sso_validate_token` reject on signature/issuer/audience/expiry mismatch (verified server-side). Scenario asserts tampered + wrong-issuer rejection. -- No new attack surface — composition of existing, contract-tested steps. +- **Asymmetric cross-service**: App B holds only App A's PUBLIC key (via JWKS); App A's private key never leaves App A. The client_credentials secret is App-A-internal (issuer-local), NOT a cross-service shared secret (rev-2 F4 correction). +- **Audience binding** (F8): App A mints `aud=app-b`; App B's verifier sets `ClientID=app-b` so go-oidc rejects tokens for other audiences; scenario asserts aud-mismatch → 401. +- **Issuer pinning**: verifier pins `issuer`; `oidc.NewVerifier` checks `iss`. Wrong-issuer/wrong-key → reject (asserted). +- **Verify-only mode**: jwksUri mode exposes no token endpoint (no exchange/refresh) — minimal surface. +- **Startup coupling** (F6 mitigation): jwksUri `NewRemoteKeySet` is lazy (fetches on first verify), so App B start does not hard-depend on App A being up at boot (unlike discovery `NewProvider`, which fetches at init). ## Infrastructure Impact -- No plugin release strictly required (docs + descriptor only). If a provider descriptor is added → workflow-plugin-auth patch release + registry manifest bump. -- Scenario: isolated docker-compose (2 engine apps + mock OIDC issuer); own port range; no cloud. -- Rollback: revert scenario PR (remove from scenarios.json); revert docs PR; no runtime consumers affected. +- workflow-plugin-sso: additive config + verify path → **patch release**; registry manifest unchanged (no new step/module type — `jwksUri` is provider config). +- Scenario: isolated docker-compose (App A + App B engine processes); ports `1809x`; no cloud, no external IDP creds (App A is the issuer). +- Rollback: §Rollback. ## Multi-Component Validation -Real boundary: **App A (issuer) ↔ App B (verifier)** across process boundaries via ES256 + JWKS, plus **app ↔ external OIDC issuer**. curl smoke proves cross-service accept/reject deterministically; Playwright + playwright-cli prove the browser verification console; both self-isolate. +Genuine cross-PROCESS boundary: App A (issuer process) ↔ App B (verifier process) over HTTP, App B fetching App A's JWKS. curl smoke asserts accept(App-A-key)/reject(other-key)/reject(aud)/reject(expired) — proving asymmetric cross-service, not same-key (F6). Playwright + playwright-cli drive the browser verification console. Both self-isolate. -## Assumptions -1. `auth.m2m` `trustedKeys[]` config + ES256 issue + `/oauth/jwks` work as read in source (verify at runtime in the scenario). -2. `sso.oidc` verifies via `issuer`+`jwksUri` against a mock OIDC issuer that serves discovery/JWKS (scenario 84 precedent uses sso.oidc against a real issuer). *If the mock-OIDC-with-discovery proves heavy:* the external-IDP leg falls back to verifying App A's `auth.m2m` token via `sso.oidc` `jwksUri=App A /oauth/jwks` OR documents the OIDC leg with the M2M leg as the proven core. -3. A browser "verification console" satisfies the browser-plane DoD without a full external-IDP login UI (the human-OIDC-login UI is documented; the console demonstrates verification visibly). +## Assumptions (verified) +1. `auth.m2m` issues ES256 + serves `/oauth/jwks` (verified `auth_m2m.go`); App A can mint a token with `aud` (client_credentials path; confirm aud-setting at runtime, else mint via a configured client with audience). +2. go-oidc v3.12.0 `NewRemoteKeySet`+`NewVerifier` verify ES256 from a JWKS without discovery (standard go-oidc; sso already imports go-oidc v3.12.0). +3. Browser "verification console" satisfies the browser-plane DoD; the human-OIDC-login redirect flow is documented (covered by existing oidc steps), not demoed (no external IDP creds in CI). ## Rollback | Change | Class | Rollback | |---|---|---| -| scenario (docker-compose stack) | isolated test asset | revert PR; remove scenarios.json entry | -| provider descriptor (if added) | manifest data + patch release | revert; don't advance tag | -| docs (README/SPEC) | docs | revert | +| sso jwksUri verify mode | additive code + patch release | revert PR; don't advance tag; discovery path unchanged | +| scenario stack | isolated test asset | revert PR; remove scenarios.json entry | +| auth docs + descriptor | docs/manifest | revert | ## Non-goals / follow-ups -- Refresh-token issuance by `auth.m2m` (it consumes/validates; OIDC providers issue refresh) — not needed; `step.sso_refresh_token` covers the consumer path. -- gocodealone-multisite adoption — tracked separately (#54). +- M2M refresh tokens (client_credentials has none — re-issue); auth.m2m multi-key verify. +- Fix scenario 84's invalid flat `sso.oidc` config (`providers:[]` format) — separate hygiene PR (F3); this design does NOT rely on scenario 84 as a precedent. +- gocodealone-multisite adoption (#54). ## Top doubts (self-challenge) -- D1: app-to-app verify path — `auth.m2m` trusted-keys (chosen, direct) vs `sso.oidc` (needs discovery/jwksUri). Resolved: trusted-keys for M2M leg; validated at runtime. -- D2: mock OIDC issuer for the external-IDP leg may be heavy → Assumption 2 fallback keeps the proven core (M2M) intact. -- D3: "zero new code" under-delivers #41? No — the value is the *proven + documented* composition + correct closure; building would duplicate the engine/providers (user-rejected). +- D1: jwksUri verify mode must check `aud`+`iss` to be a real gate (not just signature) → set ClientID=audience + pin issuer; scenario asserts both. +- D2: App A minting a token with the right `aud` via client_credentials — verify at runtime; fallback = a configured client with `audience`/`scope` mapping. +- D3: scope grew from "zero code" to "~25 LOC in sso" — but this is the honest minimum to *fully* support cross-service asymmetric verify (cycle-1's zero-code claim was based on a false reuse path); still no bespoke IDP, fits the provider pattern. + +## Cycle-1 resolutions +| id | sev | finding | resolution | +|---|---|---|---| +| F1 | Critical | `auth.m2m.Authenticate` ignores trustedKeys → cross-service verify path fictional | verify via `sso.oidc` jwksUri mode (the new gap), not trustedKeys | +| F2 | Critical | `sso.oidc` needs discovery; ignores jwksUri; no mock-OIDC exists | add jwksUri (`NewRemoteKeySet`) mode; App A's `/oauth/jwks` is the JWKS — no mock-OIDC/discovery needed | +| F6 | Critical | demo would prove same-key, not cross-service | App B holds only App A's public JWKS; assert other-key REJECTED | +| F3 | Important | scenario-84 invalid `sso.oidc` config = false precedent | don't rely on it; note + optional separate fix | +| F4 | Important | client_credentials sub=client_id, symmetric secret | clarified: secret is App-A-local; cross-service trust is the public JWKS only | +| F5 | Important | refresh only covers OIDC, not M2M | §I: OIDC=`sso_refresh_token`; M2M=re-issue | +| F8 | Important | aud not validated | App A mints aud=app-b; verifier ClientID=app-b; assert aud-mismatch reject | +| F7 | Minor | provider descriptor maybe YAGNI | add only if catalog lacks oauth2_oidc descriptor | From de69dd178e098a70447a9ec5661a843d42cbec73 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:41:42 -0400 Subject: [PATCH 3/9] =?UTF-8?q?docs(auth):=20design=20PASS=20=E2=80=94=20f?= =?UTF-8?q?old=20cycle-2=20scenario-config=20notes=20(N1/N2/N3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adversarial-design-review --phase=design: PASS (0C/0I; 3 mechanical Minors folded: App-A client claims:{aud:app-b}; issuer string exact-match; wrong-issuer assertion). --- docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md index 8e08ffc..2630421 100644 --- a/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md @@ -47,6 +47,7 @@ Make Workflow **fully support cross-service asymmetric (ES256) JWT auth** — mu - **App B (verifier)**: `sso.oidc` `jwksUri: http://app-a:8080/oauth/jwks`, `issuer`+`audience: app-b`; route gated by `step.sso_validate_token`. Asserts: App A's token **accepted** (verified from App A's PUBLIC key only); token signed by a **different** key **rejected** (proves real asymmetric, not same-key); **aud-mismatch rejected**; expired rejected. - **Browser leg (DoD)**: a small "verification console" UI on App B — fetch App A's token, POST to App B's verify route, render verified claims vs rejection — driven by **playwright-cli exploratory QA** + a committed **Playwright** test. - curl smoke (deterministic, self-isolating) + Playwright + playwright-cli QA (EXPLORATORY.md + screenshots). Register in `scenarios.json`. + - **Scenario-config notes (cycle-2 N1/N2/N3, mechanical):** App A's `auth.m2m` client MUST be registered with `claims: {aud: app-b}` (aud flows via `client.Claims` pass-through; `issueToken` doesn't set aud otherwise → go-oidc would reject when App B pins `audience`). App A's `auth.m2m.issuer` and App B's `sso.oidc.issuer` MUST be the identical exact string (go-oidc exact-match on `iss`), e.g. `http://app-a:8080`. curl smoke MUST include a **wrong-issuer rejected** case in addition to wrong-key/aud-mismatch/expired. 3. **workflow-plugin-auth** — **use-case → step/module-combination matrix** in README + SPEC §X (same-app HS256 session · app-to-app M2M asymmetric · external-IDP human OIDC · refresh · enterprise SSO/SCIM → which `auth.m2m`/`sso`/provider combination). Add an `oauth2_oidc` `AuthProviderDescriptor` only if the catalog lacks one. Close #41. ## Security Review From 2873ce2f572987ee97ac13e2729675d20659ad5b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:43:46 -0400 Subject: [PATCH 4/9] =?UTF-8?q?docs(auth):=20implementation=20plan=20auth#?= =?UTF-8?q?41=20=E2=80=94=203=20PRs=20/=209=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1 sso.oidc jwksUri verify mode (+test+v0.1.7); PR2 workflow-scenarios scenario 102 (App A auth.m2m ES256 issuer + App B sso jwksUri verifier; cross-service accept + wrong-key/aud/issuer/expired reject; verification-console + Playwright + playwright-cli QA); PR3 workflow-plugin-auth use-case->combination matrix + close #41. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-02-cross-service-asymmetric-auth.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/plans/2026-06-02-cross-service-asymmetric-auth.md diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md new file mode 100644 index 0000000..38f355c --- /dev/null +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md @@ -0,0 +1,167 @@ +# Cross-Service Asymmetric Auth (auth#41) — Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans. + +**Goal:** Make Workflow fully support cross-service asymmetric (ES256) JWT auth via reuse: add a `jwksUri`-only verify mode to the existing `workflow-plugin-sso` `sso.oidc` provider, demonstrate genuine cross-process verification in a workflow-scenarios stack, document the use-case→combination matrix in workflow-plugin-auth, and close #41. No bespoke IDP. + +**Architecture:** App A = `auth.m2m` ES256 issuer (publishes `/oauth/jwks`). App B = `sso.oidc` (new `jwksUri` mode, `oidc.NewRemoteKeySet`+`NewVerifier`) + `step.sso_validate_token` — verifies App A's token from App A's PUBLIC JWKS; no shared secret, no OIDC discovery, no mock-OIDC. + +**Tech Stack:** Go 1.26; `github.com/coreos/go-oidc/v3 v3.12.0` (`NewRemoteKeySet`/`NewVerifier`); engine `auth.m2m` (ES256); docker-compose (2 engine processes); Playwright + playwright-cli. + +**Base branch:** main (all three repos). + +**Design:** `docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md` (rev 2, adversarial PASS). ADR-0002, ADR-0003. + +--- + +## Scope Manifest + +**PR Count:** 3 +**Tasks:** 9 +**Estimated Lines of Change:** ~500 (informational) + +**Out of scope:** +- Bespoke IDP (`auth.idp`, `step.auth_jwks_serve`, asymmetric `step.auth_jwt_issue`) — engine `auth.m2m` + `sso` cover it. +- M2M refresh tokens (client_credentials has none; re-issue) + `auth.m2m` multi-key verify. +- gocodealone-multisite adoption (#54). Fixing scenario-84's invalid `sso.oidc` config (separate hygiene). + +**PR Grouping:** + +| PR # | Title | Tasks | Branch / Repo | +|------|-------|-------|--------| +| 1 | feat: sso.oidc jwksUri verify mode | Task 1, Task 2, Task 3 | feat/sso-jwks-uri-verify (workflow-plugin-sso) | +| 2 | test: scenario 102 cross-service asymmetric auth | Task 4, Task 5, Task 6 | feat/scenario-102-cross-service-asymmetric (workflow-scenarios) | +| 3 | docs: auth use-case matrix + close #41 | Task 7, Task 8, Task 9 | feat/auth-41-usecase-matrix (workflow-plugin-auth) | + +**Status:** Draft + +--- + +## PR 1 — workflow-plugin-sso: `sso.oidc` jwksUri verify mode + +Repo `/Users/jon/workspace/workflow-plugin-sso`, branch off origin/main. `GOWORK=off`. + +### Task 1: `jwksUri` verify mode (TDD) + +**Files:** Modify `internal/oidc.go` (`ProviderConfig` + `InitProvider`), `internal/module_oidc.go` (config parse); Test `internal/oidc_jwksuri_test.go`. + +**Step 1 — failing test** (`internal/oidc_jwksuri_test.go`): use the existing `mockOIDCServer` (serves `/keys` JWKS + signs ES256/RS256 tokens). Build a provider via `InitProvider(ctx, ProviderConfig{Name:"app-a", Issuer: mock.URL, JWKSURI: mock.URL+"/keys", ClientID:"app-b"})` (note: NO discovery). Assert: `provider.Verifier.Verify(ctx, validToken)` succeeds for a token with `iss=mock.URL`, `aud=app-b`; fails for a token signed by a DIFFERENT key; fails for `aud!=app-b`; fails for `iss!=mock.URL`. (Mirror `oidc_test.go`'s mockOIDCServer token-minting helpers.) + +**Step 2:** Run `GOWORK=off go test ./internal/ -run JWKSURI -v` → FAIL (JWKSURI field undefined). + +**Step 3 — implementation:** +- `internal/oidc.go` `ProviderConfig`: add `JWKSURI string` field. +- `InitProvider`: branch at the top — when `cfg.JWKSURI != ""`: + ```go + keySet := oidc.NewRemoteKeySet(ctx, cfg.JWKSURI) + verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{ + ClientID: cfg.ClientID, + SkipClientIDCheck: cfg.ClientID == "", + }) + // verify-only: no discovery, no OAuthCfg.Endpoint (refresh/exchange require discovery path) + return &OIDCProvider{ProviderName: cfg.Name, Issuer: issuer, Verifier: verifier, OAuthCfg: &oauth2.Config{ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Scopes: scopesOrDefault(cfg.Scopes)}, ClaimPaths: claimMapOrDefault(cfg.ClaimMapping)}, nil + ``` + Keep the existing `oidc.NewProvider(ctx, issuer)` discovery path for the `else` (JWKSURI=="") case unchanged. +- `internal/module_oidc.go` provider parse: add `JWKSURI: getString(raw, "jwksUri")` to the `ProviderConfig` built from `raw`. + +**Step 4:** Run `GOWORK=off go test ./internal/ -run JWKSURI -v` → PASS. + +**Step 5:** Commit `feat(sso): jwksUri-only verify mode for sso.oidc (NewRemoteKeySet, no discovery)`. + +### Task 2: Docs + manifest + full verification + +**Files:** `README.md` (document `jwksUri` provider config: verify-only, cross-service/JWKS issuers without discovery), `plugin.json` (no new step/module type → no capabilities change; leave version discipline placeholder). + +**Step — verify (Go-repo class):** +``` +GOWORK=off go test -race ./... 2>&1 | tail -10 # green +GOWORK=off go build ./... # exit 0 +GOWORK=off golangci-lint run --new-from-rev=origin/main ./... # 0 issues +``` +**Step:** Commit `docs(sso): document sso.oidc jwksUri verify mode`. + +### Task 3: PR 1, monitor, merge, tag v0.1.7 + +**Rollback:** revert PR; don't advance tag; discovery path untouched. +- Pre-tag: `git ls-remote --tags origin | grep -c 'v0.1.7$'` → 0 (latest is v0.1.6). +- PR → CI green + Copilot clear → admin-merge → `git tag v0.1.7 && git push origin v0.1.7`. +- Registry manifest: only if the sso manifest tracks version/downloads — bump version→0.1.7 + downloads (no capabilities change). If notify-sync handles it, skip. Verify `gh release view v0.1.7`. + +--- + +## PR 2 — workflow-scenarios: scenario 102 cross-service asymmetric auth + +Repo `/Users/jon/workspace/workflow-scenarios`, branch off origin/main. **Depends on PR 1 merged** (builds sso from main). Scenario id **102** (verify against origin/main `scenarios.json`; 101 is taken). Templates: scenario 101 (image-bake seed, self-isolating tests, docker-compose) + 92 (multi-service). + +### Task 4: Scenario scaffold — App A (issuer) + App B (verifier) + +**Files (under `scenarios/102-cross-service-asymmetric-auth/`):** `scenario.yaml`, `README.md`, `config/app-a.yaml`, `config/app-b.yaml`, `docker-compose.yml`, `seed/seed.sh`. + +- **`config/app-a.yaml` (issuer):** `http.server` + `router`; `auth.m2m` module `appissuer` with `algorithm: ES256` + a generated/configured EC private key + `issuer: http://app-a:8080` + a registered M2M client (`client_id`/`client_secret`) carrying **`claims: {aud: app-b}`** (cycle-2 N1 — confirm the auth.m2m `clients` config shape in `workflow/plugins/auth/plugin.go`; `issueToken` passes `client.Claims`). Mount the `auth.m2m` handler so `/oauth/token` + `/oauth/jwks` are reachable (see how `auth.m2m`/`auth.jwt` bind via `workflows.http` handler or route prefix; `auth_m2m.go` endpoints default `/oauth/token`,`/oauth/jwks`). A `GET /healthz` pipeline. +- **`config/app-b.yaml` (verifier):** `http.server` + `router`; `sso.oidc` module `verifier` with `providers: [{name: app-a, issuer: http://app-a:8080, jwksUri: http://app-a:8080/oauth/jwks, clientId: app-b}]` (**N2: issuer string identical to App A's**). Pipeline `POST /verify`: `step.request_parse` (parse_headers:[Authorization] or body token) → `step.sso_validate_token {provider: app-a, token_source: ...}` → `step.json_response` (claims on valid / 401 on invalid). `GET /healthz` + the verification-console UI route (Task 6). +- **`docker-compose.yml`:** services `app-a` (`auth-xservice-a:scenario-102`, port `18102`), `app-b` (`auth-xservice-b:scenario-102`, port `18112`), `app-b depends_on app-a healthy`. Image-bake (mirror scenario 101 seed Dockerfile: WORKDIR /home/nonroot, plugins → `./data/plugins`, `-config` flag, no leading `server`). +- **`seed/seed.sh`:** cross-compile (`GOOS=linux GOARCH=amd64`) the engine server once + the sso plugin (from `../../workflow-plugin-sso`) into `./data/plugins/workflow-plugin-sso/`; bake one image used by both services (different `-config`); `docker compose up`; wait both `/healthz`. + +**Step — verify:** `bash -n seed/seed.sh`; `wfctl validate --plugin-manifest ../../workflow-plugin-sso/plugin.json config/app-a.yaml` and `config/app-b.yaml` → pass. +**Commit:** `test(scenario-102): cross-service asymmetric auth scaffold (App A issuer + App B verifier)`. + +### Task 5: curl smoke (deterministic, self-isolating) + +**Files:** `test/run.sh` (PASS:/FAIL: prefixes). +Assertions (the genuine cross-service proof): +1. `GET app-a/healthz` + `app-b/healthz` → 200. +2. Obtain ES256 token from App A: `POST app-a:18102/oauth/token` (client_credentials, App A client) → `access_token` (decode header alg=ES256, payload aud=app-b, iss=http://app-a:8080). +3. **Accept:** `POST app-b:18112/verify` Bearer → 200 + claims (App B verified it from App A's PUBLIC JWKS — no shared secret). +4. **Reject wrong-key:** a token signed by a different EC key → 401. +5. **Reject aud-mismatch:** a token with `aud != app-b` → 401 (N3-adjacent). +6. **Reject wrong-issuer:** a token with `iss != http://app-a:8080` → 401 (**N3**). +7. **Reject expired/garbage** → 401. +(Tokens for 4-6 minted via a tiny inline helper or a second auth.m2m with a different key/issuer; or `openssl`/`go run` mint. Keep deterministic.) +**Step — verify:** run seed.sh then run.sh → `Results: N passed, 0 failed`. +**Commit:** `test(scenario-102): curl smoke — cross-service accept + wrong-key/aud/issuer/expired reject`. + +### Task 6: Browser verification console + Playwright + playwright-cli QA + register + +**Files:** `ui/index.html`(+ js) (App B "Verification Console": button fetches an App-A token via App B proxy route or pasted, POSTs `/verify`, renders verified claims vs rejection), `static.fileserver` in `app-b.yaml`; `e2e/tests/scenario-102-cross-service-asymmetric.spec.ts` (Playwright: load console → verify valid token → see claims; verify tampered → see rejection; self-reset if stateful); `test/EXPLORATORY.md` + `test/screenshots/`; `scenarios.json` entry (id 102). + +- **Playwright** (committed): drives the console; asserts valid→claims, tampered→rejected. Navigate to `http://localhost:18112`. +- **playwright-cli exploratory QA** (DoD): walk the console (fetch token → verify → claims; tamper → rejected), screenshots → EXPLORATORY.md. +**Step — verify:** Playwright `npx playwright test scenario-102-...` pass; playwright-cli QA screenshots captured. +**Commit:** `test(scenario-102): verification-console UI + Playwright + playwright-cli QA + register`. +**Rollback:** revert PR; remove scenarios.json entry. +**PR 2:** open → CI green + Copilot → admin-merge. + +--- + +## PR 3 — workflow-plugin-auth: use-case matrix + close #41 + +Repo `/Users/jon/workspace/workflow-plugin-auth` (this worktree's branch, or fresh off main). Independent of PR1/PR2 code but references them; merge last. + +### Task 7: Use-case → combination matrix doc + +**Files:** `README.md` (new "Auth use cases & combinations" section) + `SPEC.md` §X. +Matrix (use case → module/step combination): +| Use case | Combination | +|---|---| +| Same-app session (symmetric) | `step.auth_jwt_issue` (HS256) + `step.auth_validate` against `auth.jwt` | +| First-run admin bootstrap | `step.auth_bootstrap_redeem` + `step.auth_jwt_issue` (see scenario 101) | +| App-to-app M2M asymmetric (ES256) | issuer `auth.m2m` (ES256 + `/oauth/jwks`) → verifier `sso.oidc` `jwksUri` mode + `step.sso_validate_token` (see scenario 102) | +| Human/browser login (external IDP) | OIDC login (`step.oidc_auth_url`/`callback` or `step.auth_oauth_*`) → `sso.oidc` (discovery) + `step.sso_validate_token`; refresh `step.sso_refresh_token` | +| Enterprise SSO / SCIM | provider plugins (okta/auth0/entra/ory-*/scalekit) via `AuthProviderDescriptor` | +Note the asymmetric/JWKS/refresh coverage explicitly answers #41. + +### Task 8: Provider descriptor (only if missing) + +Check the provider catalog for an `oauth2_oidc` `AuthProviderDescriptor`; add one (descriptor-only, no runtime auth code) only if absent. If present, skip + note. + +### Task 9: PR 3, merge, close #41 + +- Commit docs (+ design/plan/ADR-0003 already on branch). PR → CI green + Copilot → admin-merge. +- Close #41 with evidence (sso v0.1.7 jwksUri mode + scenario 102 + use-case matrix). +- Post-merge retrospective in `docs/retros/`. +**Rollback:** revert docs PR. + +--- + +## Post-merge follow-ups (file, don't implement) +- workflow-scenarios: fix scenario-84's invalid flat `sso.oidc` config to `providers:[]` (hygiene). From 576304cf541168ee8d9faadc49e6dfb72871e3bd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:51:19 -0400 Subject: [PATCH 5/9] =?UTF-8?q?docs(auth):=20plan=20rev=202=20=E2=80=94=20?= =?UTF-8?q?resolve=20plan-phase=20adversarial=20(2I+2m)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2 App-A mounts auth.m2m via workflows.http.routes handler: appissuer (scenario-20 pattern; M2MAuthModule is HTTPHandler not http.Handler — no step.delegate); grounded auth.m2m config keys (algorithm/issuer/clients[].claims.aud). F1 unit test proves jwksUri mechanism via existing RS256 mock (alg-agnostic); ES256 proven in scenario 102. F3 deterministic negative tokens via test/mint-token Go helper. F5 App-B /proxy/token pipeline for same-origin browser fetch. F4 registry notify check. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-02-cross-service-asymmetric-auth.md | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md index 38f355c..39a8739 100644 --- a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md @@ -45,7 +45,7 @@ Repo `/Users/jon/workspace/workflow-plugin-sso`, branch off origin/main. `GOWORK **Files:** Modify `internal/oidc.go` (`ProviderConfig` + `InitProvider`), `internal/module_oidc.go` (config parse); Test `internal/oidc_jwksuri_test.go`. -**Step 1 — failing test** (`internal/oidc_jwksuri_test.go`): use the existing `mockOIDCServer` (serves `/keys` JWKS + signs ES256/RS256 tokens). Build a provider via `InitProvider(ctx, ProviderConfig{Name:"app-a", Issuer: mock.URL, JWKSURI: mock.URL+"/keys", ClientID:"app-b"})` (note: NO discovery). Assert: `provider.Verifier.Verify(ctx, validToken)` succeeds for a token with `iss=mock.URL`, `aud=app-b`; fails for a token signed by a DIFFERENT key; fails for `aud!=app-b`; fails for `iss!=mock.URL`. (Mirror `oidc_test.go`'s mockOIDCServer token-minting helpers.) +**Step 1 — failing test** (`internal/oidc_jwksuri_test.go`): use the existing `mockOIDCServer` (it serves `/keys` JWKS + mints **RS256** tokens — that is FINE; `NewRemoteKeySet`+`NewVerifier` are algorithm-agnostic, so this proves the jwksUri *verify mechanism*; ES256 specifically is proven end-to-end in scenario 102 against real `auth.m2m`, cycle-2 F1). Build a provider via `InitProvider(ctx, ProviderConfig{Name:"app-a", Issuer: mock.URL, JWKSURI: mock.URL+"/keys", ClientID:"app-b"})` (note: NO discovery — assert no request hits `/.well-known/openid-configuration`). Assert: `provider.Verifier.Verify(ctx, validToken)` succeeds for a token with `iss=mock.URL`, `aud=app-b`; fails for a token signed by a DIFFERENT key (add a 2nd mock key/server); fails for `aud!=app-b`; fails for `iss!=mock.URL`. (Reuse `oidc_test.go`'s mock token-minting helpers; do NOT extend the mock to ES256 — unneeded.) **Step 2:** Run `GOWORK=off go test ./internal/ -run JWKSURI -v` → FAIL (JWKSURI field undefined). @@ -85,7 +85,7 @@ GOWORK=off golangci-lint run --new-from-rev=origin/main ./... # 0 issues **Rollback:** revert PR; don't advance tag; discovery path untouched. - Pre-tag: `git ls-remote --tags origin | grep -c 'v0.1.7$'` → 0 (latest is v0.1.6). - PR → CI green + Copilot clear → admin-merge → `git tag v0.1.7 && git push origin v0.1.7`. -- Registry manifest: only if the sso manifest tracks version/downloads — bump version→0.1.7 + downloads (no capabilities change). If notify-sync handles it, skip. Verify `gh release view v0.1.7`. +- Registry manifest (cycle-2 F4): check `.github/workflows/release.yml` for an automatic registry notify-dispatch; if present, the registry sync is automatic → skip manual edit. Else open a tiny workflow-registry PR bumping the sso manifest version→0.1.7 + downloads (no capabilities change). Verify `gh release view v0.1.7` has assets. --- @@ -97,8 +97,35 @@ Repo `/Users/jon/workspace/workflow-scenarios`, branch off origin/main. **Depend **Files (under `scenarios/102-cross-service-asymmetric-auth/`):** `scenario.yaml`, `README.md`, `config/app-a.yaml`, `config/app-b.yaml`, `docker-compose.yml`, `seed/seed.sh`. -- **`config/app-a.yaml` (issuer):** `http.server` + `router`; `auth.m2m` module `appissuer` with `algorithm: ES256` + a generated/configured EC private key + `issuer: http://app-a:8080` + a registered M2M client (`client_id`/`client_secret`) carrying **`claims: {aud: app-b}`** (cycle-2 N1 — confirm the auth.m2m `clients` config shape in `workflow/plugins/auth/plugin.go`; `issueToken` passes `client.Claims`). Mount the `auth.m2m` handler so `/oauth/token` + `/oauth/jwks` are reachable (see how `auth.m2m`/`auth.jwt` bind via `workflows.http` handler or route prefix; `auth_m2m.go` endpoints default `/oauth/token`,`/oauth/jwks`). A `GET /healthz` pipeline. -- **`config/app-b.yaml` (verifier):** `http.server` + `router`; `sso.oidc` module `verifier` with `providers: [{name: app-a, issuer: http://app-a:8080, jwksUri: http://app-a:8080/oauth/jwks, clientId: app-b}]` (**N2: issuer string identical to App A's**). Pipeline `POST /verify`: `step.request_parse` (parse_headers:[Authorization] or body token) → `step.sso_validate_token {provider: app-a, token_source: ...}` → `step.json_response` (claims on valid / 401 on invalid). `GET /healthz` + the verification-console UI route (Task 6). +- **`config/app-a.yaml` (issuer).** GROUNDED config keys (from `plugins/auth/plugin.go` auth.m2m factory): + ```yaml + modules: + - { name: server, type: http.server, config: { address: ":8080" } } + - { name: router, type: http.router, dependsOn: [server] } + - name: appissuer + type: auth.m2m + config: + algorithm: ES256 # omit privateKey → module GenerateECDSAKey at init + issuer: http://app-a:8080 # MUST byte-match App B's provider issuer (N2) + tokenExpiry: 1h + clients: + - { clientId: app-b-caller, clientSecret: ${APP_A_CLIENT_SECRET}, claims: { aud: app-b } } # aud flows via client.Claims (N1) + dependsOn: [router] + workflows: + http: + server: server + router: router + routes: # mount auth.m2m's HTTPHandler (Handle dispatches by path-suffix); scenario-20 `handler:` pattern + - { method: POST, path: /oauth/token, handler: appissuer } + - { method: GET, path: /oauth/jwks, handler: appissuer } + pipelines: + healthz: { trigger: {type: http, config: {path: /healthz, method: GET}}, steps: [{name: ok, type: step.json_response, config: {status: 200, body: {status: ok}}}] } + ``` + (Do NOT use `step.delegate`/`api.command` — M2MAuthModule is an `HTTPHandler` (has `Handle`, not `ServeHTTP`); the `handler:` route binding via `app.GetService(name,&HTTPHandler)` is the correct, scenario-20-proven mount. cycle-2 F2.) +- **`config/app-b.yaml` (verifier):** `http.server` + `router`; `sso.oidc` module `verifier` with `providers: [{name: app-a, issuer: http://app-a:8080, jwksUri: http://app-a:8080/oauth/jwks, clientId: app-b}]` (**N2: issuer string identical to App A's**). Pipelines: + - `POST /verify`: `step.request_parse` (parse_headers:[Authorization]) → `step.sso_validate_token {provider: app-a, token_source: steps.parse.headers.Authorization}` → `step.json_response` (claims on valid / 401 on invalid). + - `POST /proxy/token` (cycle-2 F5, so the browser console + Playwright fetch a token same-origin): `step.http_call` POST `http://app-a:8080/oauth/token` (client_credentials form, App-A client creds) → `step.json_response {access_token}`. + - `GET /healthz` + the verification-console UI route (Task 6). - **`docker-compose.yml`:** services `app-a` (`auth-xservice-a:scenario-102`, port `18102`), `app-b` (`auth-xservice-b:scenario-102`, port `18112`), `app-b depends_on app-a healthy`. Image-bake (mirror scenario 101 seed Dockerfile: WORKDIR /home/nonroot, plugins → `./data/plugins`, `-config` flag, no leading `server`). - **`seed/seed.sh`:** cross-compile (`GOOS=linux GOARCH=amd64`) the engine server once + the sso plugin (from `../../workflow-plugin-sso`) into `./data/plugins/workflow-plugin-sso/`; bake one image used by both services (different `-config`); `docker compose up`; wait both `/healthz`. @@ -116,7 +143,8 @@ Assertions (the genuine cross-service proof): 5. **Reject aud-mismatch:** a token with `aud != app-b` → 401 (N3-adjacent). 6. **Reject wrong-issuer:** a token with `iss != http://app-a:8080` → 401 (**N3**). 7. **Reject expired/garbage** → 401. -(Tokens for 4-6 minted via a tiny inline helper or a second auth.m2m with a different key/issuer; or `openssl`/`go run` mint. Keep deterministic.) + +**Negative-case tokens (cycle-2 F3, deterministic):** add a tiny Go helper `test/mint-token/main.go` (cross-compiled in `seed/seed.sh` into `./data/mint-token`, or `go run` at test time) that mints an ES256 JWT with flags `-iss -aud -exp -key `. run.sh uses it to produce: wrong-key (fresh ES256 key), aud-mismatch (`-aud other`), wrong-issuer (`-iss http://evil`), expired (`-exp -1m`) tokens — all deterministic, no second auth.m2m service, no openssl-in-bash. The valid (accept) token comes from App A's real `/oauth/token` (proving the real issuer path). **Step — verify:** run seed.sh then run.sh → `Results: N passed, 0 failed`. **Commit:** `test(scenario-102): curl smoke — cross-service accept + wrong-key/aud/issuer/expired reject`. From c840e9fafa11e1fc37627173273c7bf2e93a7af6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:59:48 -0400 Subject: [PATCH 6/9] =?UTF-8?q?docs(auth):=20plan=20rev=203=20=E2=80=94=20?= =?UTF-8?q?fix=20go-oidc=20RS256-default=20trap=20(cycle-2=20F1)=20+=20F5?= =?UTF-8?q?=20token=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (runtime trap): go-oidc NewVerifier defaults to RS256-ONLY when SupportedSigningAlgs unset (verify.go:317) → would reject auth.m2m ES256 tokens while RS256 unit test passes. Fix: ProviderConfig.SigningAlgorithms + SupportedSigningAlgs default [ES256,RS256] in jwksUri branch; app-b provider signingAlgorithms:[ES256]. F5: /proxy/token form-encoding caveat + fallback to out-of-band token fill for the verification console. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-02-cross-service-asymmetric-auth.md | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md index 39a8739..b57cc96 100644 --- a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md @@ -50,19 +50,27 @@ Repo `/Users/jon/workspace/workflow-plugin-sso`, branch off origin/main. `GOWORK **Step 2:** Run `GOWORK=off go test ./internal/ -run JWKSURI -v` → FAIL (JWKSURI field undefined). **Step 3 — implementation:** -- `internal/oidc.go` `ProviderConfig`: add `JWKSURI string` field. +- `internal/oidc.go` `ProviderConfig`: add `JWKSURI string` + `SigningAlgorithms []string` fields. - `InitProvider`: branch at the top — when `cfg.JWKSURI != ""`: ```go keySet := oidc.NewRemoteKeySet(ctx, cfg.JWKSURI) + algs := cfg.SigningAlgorithms + if len(algs) == 0 { + algs = []string{"ES256", "RS256"} // CRITICAL (cycle-2 F1): go-oidc NewVerifier defaults to + // RS256-ONLY when SupportedSigningAlgs is empty (verify.go:317) — it would REJECT auth.m2m's + // ES256 tokens at runtime. Must include ES256. + } verifier := oidc.NewVerifier(issuer, keySet, &oidc.Config{ - ClientID: cfg.ClientID, - SkipClientIDCheck: cfg.ClientID == "", + ClientID: cfg.ClientID, + SkipClientIDCheck: cfg.ClientID == "", + SupportedSigningAlgs: algs, }) // verify-only: no discovery, no OAuthCfg.Endpoint (refresh/exchange require discovery path) return &OIDCProvider{ProviderName: cfg.Name, Issuer: issuer, Verifier: verifier, OAuthCfg: &oauth2.Config{ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Scopes: scopesOrDefault(cfg.Scopes)}, ClaimPaths: claimMapOrDefault(cfg.ClaimMapping)}, nil ``` Keep the existing `oidc.NewProvider(ctx, issuer)` discovery path for the `else` (JWKSURI=="") case unchanged. -- `internal/module_oidc.go` provider parse: add `JWKSURI: getString(raw, "jwksUri")` to the `ProviderConfig` built from `raw`. +- `internal/module_oidc.go` provider parse: add `JWKSURI: getString(raw, "jwksUri")` + `SigningAlgorithms: getStringSlice(raw, "signingAlgorithms")` to the `ProviderConfig` built from `raw`. +- **Test note (cycle-2 F1):** the RS256 mock test must pass `SupportedSigningAlgs:["RS256"]` (or rely on the ES256+RS256 default which includes RS256) — assert it explicitly so the RS256 pass is not accidental. ES256 is proven end-to-end in scenario 102 (real auth.m2m + default algs include ES256). **Step 4:** Run `GOWORK=off go test ./internal/ -run JWKSURI -v` → PASS. @@ -122,9 +130,9 @@ Repo `/Users/jon/workspace/workflow-scenarios`, branch off origin/main. **Depend healthz: { trigger: {type: http, config: {path: /healthz, method: GET}}, steps: [{name: ok, type: step.json_response, config: {status: 200, body: {status: ok}}}] } ``` (Do NOT use `step.delegate`/`api.command` — M2MAuthModule is an `HTTPHandler` (has `Handle`, not `ServeHTTP`); the `handler:` route binding via `app.GetService(name,&HTTPHandler)` is the correct, scenario-20-proven mount. cycle-2 F2.) -- **`config/app-b.yaml` (verifier):** `http.server` + `router`; `sso.oidc` module `verifier` with `providers: [{name: app-a, issuer: http://app-a:8080, jwksUri: http://app-a:8080/oauth/jwks, clientId: app-b}]` (**N2: issuer string identical to App A's**). Pipelines: +- **`config/app-b.yaml` (verifier):** `http.server` + `router`; `sso.oidc` module `verifier` with `providers: [{name: app-a, issuer: http://app-a:8080, jwksUri: http://app-a:8080/oauth/jwks, clientId: app-b, signingAlgorithms: [ES256]}]` (**N2: issuer byte-identical to App A's; cycle-2 F1: signingAlgorithms must list ES256**). Pipelines: - `POST /verify`: `step.request_parse` (parse_headers:[Authorization]) → `step.sso_validate_token {provider: app-a, token_source: steps.parse.headers.Authorization}` → `step.json_response` (claims on valid / 401 on invalid). - - `POST /proxy/token` (cycle-2 F5, so the browser console + Playwright fetch a token same-origin): `step.http_call` POST `http://app-a:8080/oauth/token` (client_credentials form, App-A client creds) → `step.json_response {access_token}`. + - `POST /proxy/token` (cycle-2 F5 — browser same-origin token fetch): `step.http_call` to App A `/oauth/token`. NOTE: auth.m2m's token endpoint uses `r.ParseForm()` (form-encoded), but `step.http_call` `body:` map serializes JSON. Send **form-encoded**: set `headers: {Content-Type: application/x-www-form-urlencoded}` + a pre-encoded `body_from`/raw string `grant_type=client_credentials&client_id=...&client_secret=...` (confirm step.http_call raw-body support at impl time; `pipeline_step_http_call.go`). If form-encoding via step.http_call proves awkward, FALL BACK: drop `/proxy/token` and have the Playwright/playwright-cli test fetch the token from App A's published port (`:18102/oauth/token`) out-of-band and supply it to the console's token input (Task 6). Either path keeps the cross-service verify (the console's job) intact. - `GET /healthz` + the verification-console UI route (Task 6). - **`docker-compose.yml`:** services `app-a` (`auth-xservice-a:scenario-102`, port `18102`), `app-b` (`auth-xservice-b:scenario-102`, port `18112`), `app-b depends_on app-a healthy`. Image-bake (mirror scenario 101 seed Dockerfile: WORKDIR /home/nonroot, plugins → `./data/plugins`, `-config` flag, no leading `server`). - **`seed/seed.sh`:** cross-compile (`GOOS=linux GOARCH=amd64`) the engine server once + the sso plugin (from `../../workflow-plugin-sso`) into `./data/plugins/workflow-plugin-sso/`; bake one image used by both services (different `-config`); `docker compose up`; wait both `/healthz`. @@ -150,7 +158,7 @@ Assertions (the genuine cross-service proof): ### Task 6: Browser verification console + Playwright + playwright-cli QA + register -**Files:** `ui/index.html`(+ js) (App B "Verification Console": button fetches an App-A token via App B proxy route or pasted, POSTs `/verify`, renders verified claims vs rejection), `static.fileserver` in `app-b.yaml`; `e2e/tests/scenario-102-cross-service-asymmetric.spec.ts` (Playwright: load console → verify valid token → see claims; verify tampered → see rejection; self-reset if stateful); `test/EXPLORATORY.md` + `test/screenshots/`; `scenarios.json` entry (id 102). +**Files:** `ui/index.html`(+ js) — App B "Verification Console": a **token textarea** + "Fetch from App A" button (calls App B `/proxy/token`; if that's dropped per F5, the test fills the textarea with a token obtained out-of-band) + "Verify" button (POST `/verify` with the token) → renders verified claims (valid) vs rejection (401). `static.fileserver` in `app-b.yaml`; `e2e/tests/scenario-102-cross-service-asymmetric.spec.ts` (Playwright: obtain an App-A ES256 token via App A's published `/oauth/token` in test setup → fill console → Verify → assert claims shown; then tamper the token → Verify → assert rejection shown; stateless so no DB reset needed); `test/EXPLORATORY.md` + `test/screenshots/`; `scenarios.json` entry (id 102). - **Playwright** (committed): drives the console; asserts valid→claims, tampered→rejected. Navigate to `http://localhost:18112`. - **playwright-cli exploratory QA** (DoD): walk the console (fetch token → verify → claims; tamper → rejected), screenshots → EXPLORATORY.md. From c40f3262947532935dfa7c9a0a76189579f48eed Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 07:01:32 -0400 Subject: [PATCH 7/9] chore: lock scope for cross-service-asymmetric-auth (alignment passed) Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/autodev-state/in-progress.jsonl | 6 ++++++ docs/plans/2026-06-02-cross-service-asymmetric-auth.md | 2 +- .../2026-06-02-cross-service-asymmetric-auth.md.scope-lock | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-06-02-cross-service-asymmetric-auth.md.scope-lock diff --git a/.claude/autodev-state/in-progress.jsonl b/.claude/autodev-state/in-progress.jsonl index 1278010..ba06f54 100644 --- a/.claude/autodev-state/in-progress.jsonl +++ b/.claude/autodev-state/in-progress.jsonl @@ -10,3 +10,9 @@ {"ts":"2026-06-02T06:25:46Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: plan phase cycle 2"} {"ts":"2026-06-02T06:25:53Z","ev":"skill","sk":"autodev:alignment-check","args":"design: docs/plans/2026-06-02-auth-bootstrap-redeem-design.md plan: docs/plans/2"} {"ts":"2026-06-02T06:27:43Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Check alignment: design vs plan"} +{"ts":"2026-06-02T10:33:22Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 design"} +{"ts":"2026-06-02T10:41:24Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 design cycle 2"} +{"ts":"2026-06-02T10:49:28Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan"} +{"ts":"2026-06-02T10:56:51Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan cycle 2"} +{"ts":"2026-06-02T11:00:32Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan cycle 3"} +{"ts":"2026-06-02T11:01:22Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Alignment check auth#41"} diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md index b57cc96..04940c5 100644 --- a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md @@ -33,7 +33,7 @@ | 2 | test: scenario 102 cross-service asymmetric auth | Task 4, Task 5, Task 6 | feat/scenario-102-cross-service-asymmetric (workflow-scenarios) | | 3 | docs: auth use-case matrix + close #41 | Task 7, Task 8, Task 9 | feat/auth-41-usecase-matrix (workflow-plugin-auth) | -**Status:** Draft +**Status:** Locked 2026-06-02T11:01:32Z --- diff --git a/docs/plans/2026-06-02-cross-service-asymmetric-auth.md.scope-lock b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md.scope-lock new file mode 100644 index 0000000..a735650 --- /dev/null +++ b/docs/plans/2026-06-02-cross-service-asymmetric-auth.md.scope-lock @@ -0,0 +1 @@ +e46577fd3cfc0cabf86ff5c9325a3ec284508af352cabb5f3d3b37e28050f475 From 69a1c3ef3c80a4c2f817aedeb0a2c247796a03ec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 07:52:11 -0400 Subject: [PATCH 8/9] docs(auth): use-case -> step/module combination matrix (close #41 by reuse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents which combination covers each auth use case: same-app HS256 session; first-run admin bootstrap; passkey/passwordless; app-to-app M2M asymmetric ES256 (auth.m2m issuer + sso.oidc jwksUri verifier, no shared secret — #41); external-IDP human OIDC login; enterprise SSO/SCIM (provider pattern); credential mgmt. T-AUTH-16 marks #41 closed-by-reuse (no bespoke IDP). Refs ADR-0002/0003. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 ++++++++++++++++++++++ SPEC.md | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index fb6899d..643fa98 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,28 @@ Google endpoint overrides must remain HTTPS URLs on the expected Google hosts. Insecure local test endpoints require `allow_insecure_test_oauth_endpoints: true`. +## Auth Use Cases & Combinations + +`workflow-plugin-auth` is a library of stateless auth primitives; complete auth +flows are composed from these steps plus the engine's built-in `auth.*` modules +and the provider plugins. Which combination covers which use case: + +| Use case | Combination | Demonstrated by | +|---|---|---| +| **Same-app session** (symmetric) | `step.auth_jwt_issue` (HS256) → `step.auth_validate` against an `auth.jwt` module sharing the secret | scenario 101 | +| **First-run admin bootstrap** (durable, passkey/SSO upgrade) | `step.auth_bootstrap_redeem` (count-gated) + `step.auth_jwt_issue` + `step.auth_passkey_*` | scenario 101 | +| **Passkey / passwordless** | `step.auth_passkey_*` (`auth.credential` module) · `step.auth_totp_*` · `step.auth_magic_link_*` | scenario 101 | +| **App-to-app M2M, asymmetric (ES256)** — services verify each other with no shared secret | issuer: engine **`auth.m2m`** (`algorithm: ES256`, `/oauth/token`, `/oauth/jwks`) → verifier: **`sso.oidc`** `jwksUri` mode + `step.sso_validate_token` (`workflow-plugin-sso` ≥ v0.1.8) | **scenario 102** | +| **Human / browser login via external IDP** (Auth0/Okta/Entra/Ory) | OIDC login (`step.auth_oauth_*` or engine `step.oidc_auth_url`/`step.oidc_callback`) → `sso.oidc` (discovery mode) + `step.sso_validate_token`; refresh `step.sso_refresh_token`; exchange `step.sso_token_exchange` | — | +| **Enterprise SSO / SCIM** | provider plugins (`workflow-plugin-{okta,auth0,entra,ory-kratos,ory-hydra,ory-polis,scalekit}`) advertised via `step.auth_provider_catalog` / `AuthProviderDescriptor` | — | +| **Credential management** (list, revoke, delete-min-1) | `step.auth_credential_list` / `step.auth_credential_revoke` + consumer `db_query`/`db_exec` | — | + +**Asymmetric cross-service note (issue #41):** the engine's `auth.m2m` module is +the ES256 issuer + JWKS server (no plugin-side IDP is needed or built). A verifying +app reuses `sso.oidc`'s `jwksUri` verify-only mode to validate tokens from the +issuer's published JWKS — no shared secret, no OIDC-discovery requirement. External +IDPs plug in through the same provider pattern. See ADR-0002 / ADR-0003. + ## BMW Migration Map - `step.bmw.auth_policy` -> `step.auth_methods_policy` diff --git a/SPEC.md b/SPEC.md index 5beb8e9..ead6cb8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -145,9 +145,13 @@ Bootstrap + JWT-issue invariants (design rev 4, ADR-0001/0002): | T-AUTH-13 provider catalog contracts | ✅ | `step.auth_provider_catalog`, `AuthProviderDescriptor`, dynamic admin-provider controls | | T-AUTH-14 step.auth_bootstrap_redeem (count-gated) | ✅ | `internal/step_bootstrap.go` + `internal/step_bootstrap_test.go` | | T-AUTH-15 step.auth_jwt_issue (HS256 mint) | ✅ | `internal/step_jwt_issue.go` + `internal/step_jwt_issue_test.go` | +| T-AUTH-16 cross-service asymmetric auth (#41) | ✅ by reuse | engine `auth.m2m` (ES256+JWKS) + `sso.oidc` jwksUri verify (workflow-plugin-sso v0.1.8) + provider pattern; demonstrated in workflow-scenarios#102; README "Auth Use Cases & Combinations"; ADR-0003. No bespoke IDP. | ## §X — References +- **Auth use cases → step/module combinations:** see README "Auth Use Cases & Combinations" (same-app HS256 · admin bootstrap · M2M asymmetric ES256 · external-IDP OIDC · enterprise SSO/SCIM). +- Cross-service asymmetric auth (#41): `docs/plans/2026-06-02-cross-service-asymmetric-auth-design.md` + ADR-0003; demo workflow-scenarios scenario 102. + - Cross-consumer integration tracked in [gocodealone-multisite SPEC §C13/C14/V17-V21](https://github.com/GoCodeAlone/gocodealone-multisite/blob/main/SPEC.md) - Original BMW extraction: [docs/plans/2026-04-26-auth-oauth-extraction-design.md](docs/plans/2026-04-26-auth-oauth-extraction-design.md) - Policy gate design: [docs/plans/2026-04-26-auth-policy-gate-design.md](docs/plans/2026-04-26-auth-policy-gate-design.md) From 08777fec60c083ef0c49a6ebe0fa764b53266dd9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 08:00:15 -0400 Subject: [PATCH 9/9] chore(auth): stop tracking .claude run-state in this PR + gitignore it (Copilot) --- .claude/autodev-state/in-progress.jsonl | 18 ------------------ .gitignore | 1 + 2 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 .claude/autodev-state/in-progress.jsonl create mode 100644 .gitignore diff --git a/.claude/autodev-state/in-progress.jsonl b/.claude/autodev-state/in-progress.jsonl deleted file mode 100644 index ba06f54..0000000 --- a/.claude/autodev-state/in-progress.jsonl +++ /dev/null @@ -1,18 +0,0 @@ -{"ts":"2026-06-02T05:36:35Z","ev":"skill","sk":"autodev:recording-decisions"} -{"ts":"2026-06-02T05:37:13Z","ev":"skill","sk":"autodev:adversarial-design-review","args":"--phase=design docs/plans/2026-06-02-auth-bootstrap-redeem-design.md"} -{"ts":"2026-06-02T05:47:40Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: design"} -{"ts":"2026-06-02T06:00:39Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: design cycle 2"} -{"ts":"2026-06-02T06:09:46Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: design cycle 3"} -{"ts":"2026-06-02T06:12:50Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: design cycle 4"} -{"ts":"2026-06-02T06:13:22Z","ev":"task","tt":"TaskUpdate"} -{"ts":"2026-06-02T06:13:26Z","ev":"skill","sk":"autodev:writing-plans","args":"design: docs/plans/2026-06-02-auth-bootstrap-redeem-design.md (autonomous: desig"} -{"ts":"2026-06-02T06:21:36Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: plan phase"} -{"ts":"2026-06-02T06:25:46Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: plan phase cycle 2"} -{"ts":"2026-06-02T06:25:53Z","ev":"skill","sk":"autodev:alignment-check","args":"design: docs/plans/2026-06-02-auth-bootstrap-redeem-design.md plan: docs/plans/2"} -{"ts":"2026-06-02T06:27:43Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Check alignment: design vs plan"} -{"ts":"2026-06-02T10:33:22Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 design"} -{"ts":"2026-06-02T10:41:24Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 design cycle 2"} -{"ts":"2026-06-02T10:49:28Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan"} -{"ts":"2026-06-02T10:56:51Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan cycle 2"} -{"ts":"2026-06-02T11:00:32Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Adversarial review: auth#41 plan cycle 3"} -{"ts":"2026-06-02T11:01:22Z","ev":"agent","ag":"general-purpose","bg":false,"d":"Alignment check auth#41"} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/