Task — engineering-spec@1

"Plugin OAuth-PKCE authentication — install-time authorize + 24h refresh-token rotation against auth.cyberskill.world"

draftTASK-PLUGIN-005
module plugin · class product · priority p0 · created 2026-05-19 · shipped null
depends on TASK-PLUGIN-001, TASK-AUTH-004 · blocks TASK-PLUGIN-007

§1 — Description (BCP-14 normative)

The PLUGIN module MUST implement OAuth 2.1 + RFC 7636 PKCE authentication for every plugin tool call. The bridge (TASK-PLUGIN-002) MUST require a valid access token on every tools/call request; tokens MUST be obtained via a one-time install-time authorize flow against auth.cyberskill.world.

  1. MUST require OAuth 2.1 PKCE per DEC-2440. Other auth methods (api-key, basic, bearer-static) MUST be rejected at install time by the host (manifest schema enforces; bridge double-checks). PKCE prevents code-interception attacks for native + desktop clients.
  1. MUST issue access tokens as JWT RS256 with these required claims per DEC-2441:
  1. MUST sign access tokens with the AUTH service's JWKS-published key per TASK-AUTH-004. Bridge verifies via cached JWKS (5-minute TTL).
  1. MUST issue opaque refresh tokens per DEC-2442:
  1. MUST define and enforce the scope catalogue per DEC-2443: `` cyberos:cuo:list — list_personas, list_workflows cyberos:cuo:route — route cyberos:cuo:execute — execute_workflow cyberos:memory:read — read_audit cyberos:memory:write — append_audit cyberos:skill:list — list_catalog cyberos:skill:invoke — invoke_skill ``

A tool call without the required scope MUST return error class authz_denied per TASK-PLUGIN-002 clause 7. Scope-vs-tool matrix lives in auth/scope_check.rs::REQUIRED_SCOPES.

  1. MUST present a consent screen at install time per DEC-2444. Hosts that support consent UI (Claude Code, Cowork) render the manifest's declared capabilities translated to scope list; user grants each capability explicitly. The consent screen MUST be host-rendered, not plugin-rendered (trust boundary).
  1. MUST store tokens via OS-native secret stores per DEC-2445:
  1. MUST support token revocation per DEC-2446:
  1. MUST emit memory audit rows per TASK-PLUGIN-006 for every auth event:
  1. MUST implement the PKCE handshake exactly:
  1. MUST NOT store access tokens longer than their exp claim — auth/token_store.rs MUST evict expired tokens within 60 seconds.
  1. MUST NOT expose refresh tokens outside the bridge process — even error logs and OTel spans MUST redact refresh token bytes.
  1. MUST NOT accept access tokens whose aud does not match the calling plugin's id — prevents cross-plugin token reuse.
  1. MUST NOT reuse PKCE code_verifier values — each authorize flow uses a fresh CSPRNG-generated verifier.

§2 — Why this design

Why OAuth-PKCE only (DEC-2440)? Static credentials in distributable bundles are a supply-chain liability. PKCE moves the trust from "the bundle holder" to "the user authenticating with AUTH at install time." Even if a bundle is stolen, the attacker cannot mint tokens without the user's authn factor (password + MFA).

Why audience-bound JWTs (DEC-2441, clause 2)? A JWT scoped only by signature is reusable anywhere the signer is trusted. Without aud: "plugin:<id>", a token issued for cyberos plugin could be replayed against an unrelated CyberOS service. Audience binding closes this — tools/call handlers MUST verify aud matches their plugin id.

Why 1-hour access tokens, 24-hour refresh (DEC-2441/2442)? Industry default. 1-hour access limits the blast radius of token theft to ≤ 1 hour of unauthorised actions. 24-hour refresh gives user-experience continuity without re-prompting. Operators can shorten via manifest.

Why rotation on every refresh (DEC-2446)? Without rotation, a stolen refresh token grants attacker indefinite access. With rotation, the legitimate client and attacker diverge — second-use detection at AUTH service catches the duplicate (a feature of OAuth 2.1).

Why locked scope catalogue (DEC-2443, clause 5)? Open scope strings invite naming creep ("cyberos.memory.write_v2", "memory:write", etc.). A locked catalogue with a clear pattern (cyberos:<resource>:<action>) ensures consistency across the manifest, the consent UI, and the bridge. Adding scopes requires this task or a successor.

Why mandatory consent screen (DEC-2444, clause 6)? Without consent, users grant scopes implicitly by installing the plugin. The consent screen makes the grant explicit and reviewable. Strategy §2 ("audit-chained") requires consent be a separate audit event from install.

Why OS-keychain storage (DEC-2445, clause 7)? Tokens in plaintext config files are readable by anyone with shell access. OS keychains use hardware-backed encryption where available (macOS Secure Enclave, Windows TPM, Linux kernel keyring). Falling back to encrypted file is acceptable for unattended servers but MUST be logged as a degradation.

Why 60-second revocation propagation (DEC-2446, clause 8)? Hard real-time revocation would require bridge → AUTH on every request (latency tax). 60-second cache TTL is the compromise: tenant admin can revoke within a minute, bridge calls AUTH once per minute regardless of request volume. For sensitive operations the cache MAY be bypassed (clause 5 destructive tools could check live).

Why audit every auth event (clause 9)? Strategy §2 "open audit chain" requires every credential operation be traceable. Installation, refresh, denial, revocation are all security-relevant. Without audit, debugging "why did this plugin lose access?" is impossible.

Why expire-and-evict instead of expire-and-error (clause 11)? Keeping expired tokens in memory invites use-after-expire bugs in handler code. Active eviction makes the invariant compile-time: a token in the store is valid.

Why aud check separately from signature check (clause 13)? Signature proves provenance; aud proves intent. A correctly-signed but wrong-audience token is a misrouted token, not a forged one. Different error message, same denial.


§3 — API contract

Postgres schema for grants

-- migrations/0001_plugin_auth_grants.sql
CREATE SCHEMA IF NOT EXISTS plugin_host;

CREATE TABLE plugin_host.grants (
  grant_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,
  subject_id UUID NOT NULL,
  plugin_id TEXT NOT NULL,
  refresh_token_hash BYTEA NOT NULL,       -- SHA-256 of refresh token; raw never stored
  refresh_token_issued_at TIMESTAMPTZ NOT NULL,
  refresh_token_expires_at TIMESTAMPTZ NOT NULL,
  scopes TEXT[] NOT NULL,
  jti TEXT,                                 -- current access token jti (for revocation cache)
  revoked_at TIMESTAMPTZ,
  trace_id CHAR(32),
  UNIQUE (tenant_id, subject_id, plugin_id)
);
ALTER TABLE plugin_host.grants ENABLE ROW LEVEL SECURITY;
CREATE POLICY grants_rls ON plugin_host.grants
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
CREATE INDEX ON plugin_host.grants (refresh_token_hash);
CREATE INDEX ON plugin_host.grants (jti) WHERE jti IS NOT NULL;

Authorize request URL

https://auth.cyberskill.world/v1/oauth/authorize
  ?response_type=code
  &client_id=plugin:cyberos
  &redirect_uri=http://127.0.0.1:7421/oauth/callback   (host-allocated localhost port)
  &scope=cyberos:cuo:execute%20cyberos:memory:read%20cyberos:skill:list
  &state=<random-32-byte-hex>
  &code_challenge=<base64url-sha256-of-verifier>
  &code_challenge_method=S256

Token request

POST /v1/oauth/token HTTP/1.1
Host: auth.cyberskill.world
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=http://127.0.0.1:7421/oauth/callback
&client_id=plugin:cyberos
&code_verifier=<original-verifier>

Token response

{
  "access_token": "<jwt rs256>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<opaque-256-bit>",
  "scope": "cyberos:cuo:execute cyberos:memory:read cyberos:skill:list"
}

Refresh request

POST /v1/oauth/token HTTP/1.1
Host: auth.cyberskill.world
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<previous-refresh-token>
&client_id=plugin:cyberos

Response is identical shape; refresh_token is NEW (rotation).

Scope-vs-tool matrix (Rust)

// services/plugin-host/src/auth/scope_check.rs
pub const REQUIRED_SCOPES: &[(&str, &[&str])] = &[
    ("cyberos.cuo.list_personas",   &["cyberos:cuo:list"]),
    ("cyberos.cuo.list_workflows",  &["cyberos:cuo:list"]),
    ("cyberos.cuo.route",           &["cyberos:cuo:route"]),
    ("cyberos.cuo.execute_workflow",&["cyberos:cuo:execute", "cyberos:memory:write"]),
    ("cyberos.memory.read_audit",    &["cyberos:memory:read"]),
    ("cyberos.memory.append_audit",  &["cyberos:memory:write"]),
    ("cyberos.skill.list_catalog",  &["cyberos:skill:list"]),
    ("cyberos.skill.invoke_skill",  &["cyberos:skill:invoke"]),
];

§4 — Acceptance criteria

  1. PKCE handshake completes — end-to-end test: bridge generates verifier, hits authorize, AUTH validates challenge, token issued.
  2. Access token is RS256 JWT — header.alg == "RS256"; payload has required claims.
  3. aud claim matches plugin idaud == "plugin:cyberos" for cyberos plugin.
  4. Cross-plugin aud rejection — token issued for plugin:cyberos is rejected when calling plugin:cyberos-vn bridge.
  5. Access token lifetime = 3600s — test checks exp - iat == 3600.
  6. Refresh token lifetime = 86400s default — verify in DB row.
  7. Refresh token rotates on use — call refresh; old refresh rejected on subsequent use.
  8. JWKS rotation respected — when AUTH rotates signing key, bridge verifies with new key within 5 minutes (cache TTL).
  9. Scope check denies missing scope — token with only cyberos:memory:read calling cyberos.memory.append_audit returns authz_denied.
  10. Multi-scope required toolcyberos.cuo.execute_workflow requires both cuo:execute AND memory:write; absent either fails.
  11. PKCE verifier is fresh per flow — two consecutive authorize flows use different verifiers.
  12. State CSRF check enforced — callback with mismatched state fails.
  13. Token stored in OS keychain (macOS) — integration test on macOS verifies Keychain item created.
  14. Encrypted file fallback on Linux without libsecret — test simulates absence, verifies file at ~/.config/cyberos-plugin/tokens.enc with mode 0600.
  15. Revocation propagates within 60s — revoke token; subsequent call within 60s may succeed (cache stale); within 120s MUST fail.
  16. plugin.installed audit emitted at install — DB query for kind='plugin.installed' returns 1 row post-install.
  17. plugin.auth_refreshed audit emitted on refresh — DB query returns row per refresh.
  18. plugin.scope_denied audit emitted on denial — DB query for failed scope check.
  19. No refresh token in logs — grep test on stdout/stderr/OTel for refresh bytes returns 0 matches.
  20. api-key auth method rejected at install — manifest with auth.method: "api-key" fails TASK-PLUGIN-001 validation, install blocked.
  21. PKCE code_verifier ≥ 43 chars — RFC 7636 §4.1.
  22. code_challenge_method MUST be S256plain rejected.

§5 — Verification

// services/plugin-host/tests/pkce_handshake_test.rs
#[tokio::test]
async fn pkce_handshake_end_to_end() {
    let bridge = TestBridge::new().await;
    let (verifier, challenge) = bridge.gen_pkce();
    let auth_url = bridge.build_authorize_url(&challenge);
    assert!(auth_url.contains("code_challenge_method=S256"));
    let code = TestAuth::mock_authorize(&auth_url).await;
    let token = bridge.exchange_code(&code, &verifier).await.unwrap();
    assert!(token.access_token.starts_with("eyJ"));
    let claims = decode_jwt(&token.access_token);
    assert_eq!(claims["aud"], "plugin:cyberos");
    assert!(claims["exp"].as_i64().unwrap() - claims["iat"].as_i64().unwrap() == 3600);
}
// services/plugin-host/tests/jwt_audience_binding_test.rs
#[tokio::test]
async fn wrong_audience_rejected() {
    let bridge = TestBridge::new_for_plugin("cyberos-vn").await;
    let token = TestAuth::issue_jwt(json!({"aud":"plugin:cyberos","sub":"...","scope":"..."}));
    let resp = bridge.tools_call_with_token("cyberos.cuo.list_personas", json!({}), &token).await;
    assert_eq!(resp["error"]["data"]["class"], "authz_denied");
    assert!(resp["error"]["data"]["hint"].as_str().unwrap().contains("audience"));
}
// services/plugin-host/tests/refresh_token_rotation_test.rs
#[tokio::test]
async fn refresh_token_rotates() {
    let bridge = TestBridge::new().await.authenticated().await;
    let old_refresh = bridge.current_refresh_token();
    let new = bridge.refresh().await.unwrap();
    assert_ne!(old_refresh, new.refresh_token);
    let resp = bridge.try_refresh_with(&old_refresh).await;
    assert!(resp.is_err());  // old refresh denied
}
// services/plugin-host/tests/scope_denial_test.rs
#[tokio::test]
async fn missing_scope_denies_call() {
    let bridge = TestBridge::new_with_scopes(&["cyberos:memory:read"]).await;
    let resp = bridge.tools_call("cyberos.memory.append_audit", json!({"kind":"x","body":{}})).await;
    assert_eq!(resp["error"]["data"]["class"], "authz_denied");
    let missing: Vec<String> = serde_json::from_value(resp["error"]["data"]["missing_scopes"].clone()).unwrap();
    assert_eq!(missing, vec!["cyberos:memory:write"]);
}

#[tokio::test]
async fn multi_scope_tool_requires_all() {
    let bridge = TestBridge::new_with_scopes(&["cyberos:cuo:execute"]).await;  // missing memory:write
    let resp = bridge.tools_call("cyberos.cuo.execute_workflow", workflow_args()).await;
    assert_eq!(resp["error"]["data"]["class"], "authz_denied");
}
// services/plugin-host/tests/revocation_propagation_test.rs
#[tokio::test]
async fn revocation_propagates_within_60s() {
    let bridge = TestBridge::new().await.authenticated().await;
    TestAuth::revoke(&bridge.current_jti()).await;
    bridge.advance_time(Duration::from_secs(120)).await;
    let resp = bridge.tools_call("cyberos.cuo.list_personas", json!({})).await;
    assert_eq!(resp["error"]["data"]["class"], "authz_denied");
}

§6 — Implementation skeleton

(API contract above + Postgres schema are the skeleton. auth/ directory has 5 files for ~600 lines of Rust.)


§7 — Dependencies


§8 — Example payloads

(See §3 for authorize URL, token request/response, refresh shape, schema.)

plugin.installed memory audit row:

{
  "kind": "plugin.installed",
  "actor_id": "<subject_uuid>",
  "tenant_id": "<tenant_uuid>",
  "body": {
    "plugin_id": "cyberos",
    "plugin_version": "1.0.0",
    "granted_scopes": ["cyberos:cuo:execute","cyberos:memory:read"],
    "consent_screen_version": "v1",
    "trace_id": "01HX..."
  }
}

plugin.scope_denied audit row:

{
  "kind": "plugin.scope_denied",
  "actor_id": "<subject_uuid>",
  "tenant_id": "<tenant_uuid>",
  "body": {
    "tool": "cyberos.memory.append_audit",
    "missing_scopes": ["cyberos:memory:write"],
    "trace_id": "01HX..."
  }
}

§9 — Open questions

All resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Authorize redirect to non-localhost URIOAuth spec checks redirect_uri prefixrefuse authUse 127.0.0.1 callback only
CSRF state mismatchcallback handlerfail with hintRestart authorize flow
Code reuse attemptAUTH rejects second exchangeerrorinherent (one-time-use code)
JWT signature invalidbridge verifyauthz_deniedRefresh token; if still bad → AUTH key rotation in flight, retry in 5min
JWT exp expiredexp claim checkauthz_denied with hint "refresh"Bridge auto-refreshes; client retries
Refresh token expiredDB lookupauthz_denied with hint "re-authenticate"Trigger consent flow
Refresh token reuseAUTH detects duplicate (rotation invariant)revoke all related grantsUser re-authenticates; admin notified via TASK-OBS-007
Cross-plugin tokenaud claim mismatchauthz_denied with hint "audience"Use the right plugin's token
Missing scopescope_check.rsauthz_denied with missing_scopes arrayUser re-authorises with broader scope
Revoked token within cache TTLcache stalerequest succeeds (≤ 60s window)Acceptable per DEC-2446; next refresh detects
Keychain unavailableOS API errorwarn + fallback to encrypted fileLog to OTel; operator restores keychain
Encrypted file permissions wrongos.stat checkwarn + force-chmod 0600Inherent fix on next boot
Time skew between bridge + AUTHiat/exp driftJWT rejectedUse NTP; AUTH MAY tolerate ±30s skew
Plugin manifest auth.method != "oauth-pkce"TASK-PLUGIN-001 schema checkinstall failsAuthor fixes manifest
Concurrent refresh raceDB unique constraint on (tenant,subject,plugin)last writer wins; first refresh response staleBridge retries with stale → triggers re-auth
Browser cannot open (headless server)host UI absentauthorize failsUse task-PLUGIN-005a Device Code flow when shipped

§11 — Implementation notes


End of TASK-PLUGIN-005 spec.