From 5d751eb1719765b45e55a5a85b805b794edb21d8 Mon Sep 17 00:00:00 2001 From: saltbo Date: Fri, 4 Sep 2026 15:03:47 -0400 Subject: [PATCH 1/2] feat(todoist): add managed read-only adapter --- .dev.vars.example | 1 + README.md | 8 + README.zh-CN.md | 1 + docs/architecture.md | 7 + migrations/0011_managed_oauth_credentials.sql | 12 + providers/todoist/README.md | 27 ++ public/.well-known/agent-skills/index.json | 7 + .../.well-known/agent-skills/todoist/SKILL.md | 30 +++ specs/todoist-adapter.feature | 23 ++ src/core/dynamic-oauth-client.ts | 50 ++-- src/core/managed-oauth.ts | 232 ++++++++++++++++++ src/providers/context7/adapter.ts | 30 +-- src/providers/context7/oauth.ts | 52 +--- src/providers/todoist/adapter.ts | 83 +++++++ src/providers/todoist/config.ts | 35 +++ src/providers/todoist/oauth.ts | 37 +++ src/providers/todoist/openapi.ts | 154 ++++++++++++ src/worker.ts | 58 ++++- test/core/dynamic-oauth-client.test.ts | 56 ++++- .../managed-oauth-credentials.test.ts | 40 +++ test/providers/todoist-oauth.test.ts | 72 ++++++ test/providers/todoist-openapi.test.ts | 21 ++ vitest.config.ts | 5 + worker-configuration.d.ts | 10 +- wrangler.jsonc | 10 +- 25 files changed, 976 insertions(+), 85 deletions(-) create mode 100644 migrations/0011_managed_oauth_credentials.sql create mode 100644 providers/todoist/README.md create mode 100644 public/.well-known/agent-skills/todoist/SKILL.md create mode 100644 specs/todoist-adapter.feature create mode 100644 src/core/managed-oauth.ts create mode 100644 src/providers/todoist/adapter.ts create mode 100644 src/providers/todoist/config.ts create mode 100644 src/providers/todoist/oauth.ts create mode 100644 src/providers/todoist/openapi.ts create mode 100644 test/integration/managed-oauth-credentials.test.ts create mode 100644 test/providers/todoist-oauth.test.ts create mode 100644 test/providers/todoist-openapi.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index d8167f2..3883c5e 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -17,6 +17,7 @@ CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY= CONTEXT7_API_ORIGIN=https://context7.com/api CONTEXT7_OAUTH_ISSUER=https://clerk.context7.com CONTEXT7_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +TODOIST_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key LINEAR_API_ORIGIN=https://api.linear.app LINEAR_AUTHORIZATION_ORIGIN=https://linear.app LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id diff --git a/README.md b/README.md index 03ca31f..f623e49 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ identity model has already passed a capability review. | Linear | Provider-delegated native App actor | Shared App user with trusted per-operation Agent attribution | 1 | Experimental | | Cloudflare | Native service principal | Dedicated account-owned token actor in audit logs | 1 | Design | | Context7 | Provider-delegated user | Shared OAuth user grant with Agent-attributed adapter audit | 1 | Experimental | +| Todoist | Provider-delegated user | Read-only OAuth user grant with Agent-attributed adapter audit | 1 | Experimental | | GitLab | Native service principal | Dedicated service account visible in groups, projects, and audit records | 2 | Proposal | | Bitbucket | Native service principal | Repository, project, or workspace access-token actor | 2 | Proposal | | Vercel | Native service principal | Dedicated integration identity with provider-side audit correlation | 2 | Proposal | @@ -163,6 +164,7 @@ providers/ github/ Provider capability report cloudflare/ Provider design and implementation linear/ Provider design and implementation + todoist/ Managed read-only OAuth provider configuration docs/ architecture.md github-design.md @@ -221,6 +223,12 @@ public OAuth client and uses S256 PKCE. Set only a base64-encoded 32-byte `CONTEXT7_CREDENTIAL_ENCRYPTION_KEY`; the Adapter persists the resulting public client ID and encrypts controller credentials in D1. +Todoist exercises the same runtime with provider endpoints on different hosts, +comma-separated authorization scopes, a provider-specific identity shape, and +no public-client token revocation. Set only `TODOIST_CREDENTIAL_ENCRYPTION_KEY`; +the Adapter dynamically registers the public client and publishes read-only +project and task collections. + Configure the GitHub App callbacks as: ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index 6561149..a8c6015 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,6 +64,7 @@ Agent 能以自己的稳定身份直接进入各种平台。详见 | Linear | 代理的原生 App actor | 共享 App user,以及逐次操作中的可信 Agent 名称/头像 | 1 | 实验性 | | Cloudflare | 原生 service principal | 独立 account-owned token actor 出现在审计日志中 | 1 | 设计中 | | Context7 | 代理用户身份 | 共享 OAuth 用户授权,并由 Adapter 审计记录具体 Agent | 1 | 实验性 | +| Todoist | 代理用户身份 | 只读 OAuth 用户授权,并由 Adapter 审计记录具体 Agent | 1 | 实验性 | | GitLab | 原生 service principal | 独立 service account 出现在 group、project 与审计记录中 | 2 | 提案 | | Bitbucket | 原生 service principal | repository、project 或 workspace access-token actor | 2 | 提案 | | Vercel | 原生 service principal | 独立 integration 身份,并可关联平台侧审计 | 2 | 提案 | diff --git a/docs/architecture.md b/docs/architecture.md index e1690b9..a4cfa50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,6 +174,13 @@ is stabilized. It is expected to contain these cohesive capabilities: The application behavior owns these contracts. Provider SDK objects, HTTP responses, token formats, and error types remain inside provider adapters. +Managed OpenAPI providers reuse a declarative runtime for explicit operation +allowlists, OAuth endpoints, scope serialization, encrypted credentials, +refresh rotation, external authorization, and request forwarding. A new +provider supplies endpoint configuration, an identity decoder, an OpenAPI +contract, and an Agent-to-provider scope mapping. Capabilities such as upstream +revocation remain optional and are declared as readiness gaps when absent. + ## Capability manifest Every provider will publish a machine-readable, versioned manifest containing diff --git a/migrations/0011_managed_oauth_credentials.sql b/migrations/0011_managed_oauth_credentials.sql new file mode 100644 index 0000000..54d54d6 --- /dev/null +++ b/migrations/0011_managed_oauth_credentials.sql @@ -0,0 +1,12 @@ +CREATE TABLE managed_oauth_credential ( + provider_id TEXT NOT NULL, + subject TEXT NOT NULL, + display_name TEXT NOT NULL, + access_token_ciphertext TEXT NOT NULL, + refresh_token_ciphertext TEXT NOT NULL, + token_expires_at INTEGER NOT NULL, + provider_scope_json TEXT NOT NULL, + credential_version INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL, + PRIMARY KEY (provider_id, subject) +); diff --git a/providers/todoist/README.md b/providers/todoist/README.md new file mode 100644 index 0000000..2ecf710 --- /dev/null +++ b/providers/todoist/README.md @@ -0,0 +1,27 @@ +# Todoist Adapter + +The Todoist adapter exposes a deliberately small, read-only Resource Server at +`/todoist`. It uses Todoist's RFC 7591 dynamic client registration with a +public OAuth client and PKCE, so operators do not provision a Todoist client ID +or client secret. + +## Published operations + +- `GET /todoist/projects` maps to `GET /api/v1/projects`. +- `GET /todoist/tasks` maps to `GET /api/v1/tasks`. + +Both operations require the Agent-facing `tasks:read` scope, which maps to +Todoist's `data:read` provider scope. + +## Configuration + +The provider URLs have production defaults. Set +`TODOIST_CREDENTIAL_ENCRYPTION_KEY` to a base64-encoded 32-byte key to enable +the adapter. Optional URL overrides are declared in `wrangler.jsonc` for local +or test environments. + +The adapter persists one dynamically registered public client and stores each +provider credential encrypted in D1. Todoist's current revocation endpoints +require confidential-client authentication, so disconnecting revokes the +adapter-side grant and deletes the local credential but cannot revoke the +upstream public-client grant. diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json index 3ff538f..a13d3f0 100644 --- a/public/.well-known/agent-skills/index.json +++ b/public/.well-known/agent-skills/index.json @@ -7,6 +7,13 @@ "description": "Use Context7 through Realmroot Toolbox to resolve a software library and retrieve current, task-relevant documentation without handling Context7 credentials.", "url": "/.well-known/agent-skills/context7/SKILL.md", "digest": "sha256:4393c50b664853900c0d42b26842fb736be05a89e34d9bfb4b00ef98b69d9869" + }, + { + "name": "use-todoist", + "type": "skill-md", + "description": "Use Todoist through Realmroot Toolbox to list the user's active projects and tasks with controller-approved, read-only Agent authority.", + "url": "/.well-known/agent-skills/todoist/SKILL.md", + "digest": "sha256:074c2632102ab7d034f579b91c3e49c53da2095a5d9ec6c806bc5cc1ce9a659f" } ] } diff --git a/public/.well-known/agent-skills/todoist/SKILL.md b/public/.well-known/agent-skills/todoist/SKILL.md new file mode 100644 index 0000000..52f73f4 --- /dev/null +++ b/public/.well-known/agent-skills/todoist/SKILL.md @@ -0,0 +1,30 @@ +--- +name: use-todoist +description: Use Todoist through Realmroot Toolbox to list the user's active projects and tasks with controller-approved, read-only Agent authority. +--- + +# Use Todoist + +Use the `realmroot` command so every Todoist request runs as the Agent with +controller-approved authority. Never ask the user for a Todoist API token. + +Before the first operation, run: + +```bash +realmroot toolbox todoist +``` + +If the command reports missing access, request only the `tasks:read` scope and +continue after controller approval. + +Use the published resources as follows: + +1. Call `GET /projects` to discover active projects. Preserve `next_cursor` + when another page is needed. +2. Call `GET /tasks` to list active tasks. Prefer narrowing by `project_id`, + `section_id`, `parent_id`, `label`, or explicit `ids` when the task permits. +3. Follow cursor pagination until `next_cursor` is null or enough information + has been gathered. + +This Resource is read-only. Do not infer that creating, completing, updating, +or deleting Todoist data is available. diff --git a/specs/todoist-adapter.feature b/specs/todoist-adapter.feature new file mode 100644 index 0000000..f866d2b --- /dev/null +++ b/specs/todoist-adapter.feature @@ -0,0 +1,23 @@ +Feature: Todoist managed OpenAPI adapter + + Scenario: Todoist provider OAuth + Given Todoist supports dynamic public-client registration + When the adapter starts an authorization code flow + Then it uses PKCE and requests only read-only provider scopes + And encrypted credentials are stored per provider subject + + Scenario: Todoist contract + Given the Todoist adapter is enabled + When an Agent discovers the Resource Server + Then the OpenAPI document publishes project and task collections + And every operation requires the tasks:read scope + + Scenario: Todoist project discovery + Given an Agent has approved tasks:read access + When it lists Todoist projects + Then the adapter forwards the request with the delegated Todoist credential + + Scenario: Todoist task discovery + Given an Agent has approved tasks:read access + When it lists Todoist tasks with optional collection filters + Then the adapter forwards only the published query operation diff --git a/src/core/dynamic-oauth-client.ts b/src/core/dynamic-oauth-client.ts index 3206989..59e64fa 100644 --- a/src/core/dynamic-oauth-client.ts +++ b/src/core/dynamic-oauth-client.ts @@ -22,6 +22,14 @@ export type DynamicOAuthToken = Readonly<{ scopes: readonly string[] }> +export type DynamicOAuthEndpoints = Readonly<{ + authorization: string + registration: string + token: string + userInfo: string + revocation?: string +}> + export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistrationStore { constructor(private readonly db: D1Database) {} @@ -47,25 +55,27 @@ export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistration export function createDynamicOAuthClient(input: { providerId: string clientName: string - issuer: string + endpoints: DynamicOAuthEndpoints redirectUri: string scopes: readonly string[] + authorizationScopeSeparator?: ' ' | ',' registrationStore: DynamicOAuthRegistrationStore fetcher?: typeof fetch now?: () => number }) { const fetcher = input.fetcher ?? fetch const now = input.now ?? Date.now + const revocationEndpoint = input.endpoints.revocation return { async authorizationUrl(state: string) { const verifier = randomVerifier() const clientId = await registeredClientId() - const url = new URL('/oauth/authorize', input.issuer) + const url = new URL(input.endpoints.authorization) url.searchParams.set('client_id', clientId) url.searchParams.set('redirect_uri', input.redirectUri) url.searchParams.set('response_type', 'code') - url.searchParams.set('scope', input.scopes.join(' ')) + url.searchParams.set('scope', input.scopes.join(input.authorizationScopeSeparator ?? ' ')) url.searchParams.set('state', state) url.searchParams.set('code_challenge', await sha256Base64Url(verifier)) url.searchParams.set('code_challenge_method', 'S256') @@ -82,17 +92,25 @@ export function createDynamicOAuthClient(input: { refresh(refreshToken: string) { return tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken }) }, - async revoke(token: string) { - const response = await fetcher(new URL('/oauth/token/revoke', input.issuer), { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: await registeredClientId() }), - signal: AbortSignal.timeout(10_000), - }) - if (!response.ok) throw providerFailure(response, 'OAuth token revocation') - }, + ...(revocationEndpoint + ? { + async revoke(token: string) { + const response = await fetcher(new URL(revocationEndpoint), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + token, + token_type_hint: 'refresh_token', + client_id: await registeredClientId(), + }), + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw providerFailure(response, 'OAuth token revocation') + }, + } + : {}), async userInfo(accessToken: string) { - const response = await fetcher(new URL('/oauth/userinfo', input.issuer), { + const response = await fetcher(new URL(input.endpoints.userInfo), { headers: { authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(10_000), }) @@ -104,7 +122,7 @@ export function createDynamicOAuthClient(input: { async function registeredClientId() { const existing = await input.registrationStore.clientId(input.providerId) if (existing) return existing - const response = await fetcher(new URL('/oauth/register', input.issuer), { + const response = await fetcher(new URL(input.endpoints.registration), { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ @@ -125,7 +143,7 @@ export function createDynamicOAuthClient(input: { } async function tokenRequest(parameters: Record): Promise { - const response = await fetcher(new URL('/oauth/token', input.issuer), { + const response = await fetcher(new URL(input.endpoints.token), { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ ...parameters, client_id: await registeredClientId() }), @@ -151,7 +169,7 @@ function randomVerifier() { } function normalizeScopes(value: string | readonly string[]) { - const scopes = typeof value === 'string' ? value.split(/\s+/) : value + const scopes = typeof value === 'string' ? value.split(/[\s,]+/) : value return [...new Set(scopes.filter(Boolean))].sort() } diff --git a/src/core/managed-oauth.ts b/src/core/managed-oauth.ts new file mode 100644 index 0000000..39bfc4f --- /dev/null +++ b/src/core/managed-oauth.ts @@ -0,0 +1,232 @@ +import { z } from 'zod' +import type { CredentialCipher } from './credential-cipher.js' +import type { DynamicOAuthToken } from './dynamic-oauth-client.js' +import type { ExternalProviderAuthorization } from './external-authorization-server.js' +import { failedDependency, forbidden } from './problem.js' + +export type ManagedOAuthCredential = Readonly<{ + subject: string + displayName: string + accessToken: string + refreshToken: string + expiresAt: number + providerScopes: readonly string[] + credentialVersion: number +}> + +export type ManagedOAuthCredentials = { + sealVerifier(verifier: string): Promise + openVerifier(verifier: string): Promise + upsert(identity: { subject: string; displayName: string }, token: DynamicOAuthToken): Promise + credential(subject: string): Promise + replace(credential: ManagedOAuthCredential, token: DynamicOAuthToken): Promise + revoke(subject: string): Promise +} + +export type ManagedOAuthClient = { + authorizationUrl(state: string): Promise<{ url: string; verifier: string }> + exchangeCode(code: string, verifier: string): Promise + refresh(refreshToken: string): Promise + revoke?(token: string): Promise + userInfo(accessToken: string): Promise +} + +export class D1ManagedOAuthCredentials implements ManagedOAuthCredentials { + constructor( + private readonly providerId: string, + private readonly providerName: string, + private readonly db: D1Database, + private readonly cipher: CredentialCipher, + ) {} + + sealVerifier(verifier: string) { + return this.cipher.seal(verifier, `${this.providerId}:oauth-intent:pkce`) + } + + openVerifier(verifier: string) { + return this.cipher.open(verifier, `${this.providerId}:oauth-intent:pkce`) + } + + async upsert(identity: { subject: string; displayName: string }, token: DynamicOAuthToken) { + if (!token.refreshToken) throw failedDependency(`${this.providerName} did not issue the required refresh token.`) + const context = `${this.providerId}:${identity.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.seal(token.accessToken, `${context}:access`), + this.cipher.seal(token.refreshToken, `${context}:refresh`), + ]) + await this.db + .prepare( + `INSERT INTO managed_oauth_credential + (provider_id, subject, display_name, access_token_ciphertext, refresh_token_ciphertext, + token_expires_at, provider_scope_json, credential_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(provider_id, subject) DO UPDATE SET display_name = excluded.display_name, + access_token_ciphertext = excluded.access_token_ciphertext, + refresh_token_ciphertext = excluded.refresh_token_ciphertext, + token_expires_at = excluded.token_expires_at, provider_scope_json = excluded.provider_scope_json, + credential_version = managed_oauth_credential.credential_version + 1, + updated_at = excluded.updated_at`, + ) + .bind( + this.providerId, + identity.subject, + identity.displayName, + accessToken, + refreshToken, + token.expiresAt, + JSON.stringify(token.scopes), + Date.now(), + ) + .run() + } + + async credential(subject: string): Promise { + const row = await this.db + .prepare( + `SELECT subject, display_name AS displayName, access_token_ciphertext AS accessToken, + refresh_token_ciphertext AS refreshToken, token_expires_at AS expiresAt, + provider_scope_json AS providerScopesJson, credential_version AS credentialVersion + FROM managed_oauth_credential WHERE provider_id = ? AND subject = ?`, + ) + .bind(this.providerId, subject) + .first<{ + subject: string + displayName: string + accessToken: string + refreshToken: string + expiresAt: number + providerScopesJson: string + credentialVersion: number + }>() + if (!row) throw forbidden(`Active ${this.providerName} authorization is required.`) + const context = `${this.providerId}:${row.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.open(row.accessToken, `${context}:access`), + this.cipher.open(row.refreshToken, `${context}:refresh`), + ]) + return { + ...row, + accessToken, + refreshToken, + providerScopes: z.array(z.string()).parse(JSON.parse(row.providerScopesJson)), + } + } + + async replace(credential: ManagedOAuthCredential, token: DynamicOAuthToken) { + const context = `${this.providerId}:${credential.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.seal(token.accessToken, `${context}:access`), + this.cipher.seal(token.refreshToken ?? credential.refreshToken, `${context}:refresh`), + ]) + const result = await this.db + .prepare( + `UPDATE managed_oauth_credential SET access_token_ciphertext = ?, refresh_token_ciphertext = ?, + token_expires_at = ?, provider_scope_json = ?, credential_version = credential_version + 1, updated_at = ? + WHERE provider_id = ? AND subject = ? AND credential_version = ?`, + ) + .bind( + accessToken, + refreshToken, + token.expiresAt, + JSON.stringify(token.scopes), + Date.now(), + this.providerId, + credential.subject, + credential.credentialVersion, + ) + .run() + return result.meta.changes === 1 + } + + async revoke(subject: string) { + await this.db + .prepare('DELETE FROM managed_oauth_credential WHERE provider_id = ? AND subject = ?') + .bind(this.providerId, subject) + .run() + } +} + +export function createManagedOAuthExternalAuthorization(input: { + id: string + name: string + origin: string + agentScopes: readonly string[] + provider: ManagedOAuthClient + credentials: ManagedOAuthCredentials + identity(value: unknown): { subject: string; displayName: string } +}): ExternalProviderAuthorization { + return { + id: input.id, + resource: `${input.origin}/${input.id}`, + scopes: ['openid', 'profile', 'email', 'offline_access', ...input.agentScopes], + async validateGrant({ subject }) { + await input.credentials.credential(subject) + return true + }, + async revoke(subject) { + const credential = await input.credentials.credential(subject) + if (input.provider.revoke) await input.provider.revoke(credential.refreshToken) + await input.credentials.revoke(subject) + }, + async begin({ providerState }) { + const started = await input.provider.authorizationUrl(providerState) + return { + url: started.url, + stage: 'provider', + data: { verifier: await input.credentials.sealVerifier(started.verifier) }, + } + }, + async complete({ callbackUrl, intent }) { + const code = new URL(callbackUrl).searchParams.get('code') + if (!code) throw failedDependency(`${input.name} OAuth callback did not include a code.`) + const encryptedVerifier = intent.providerData.verifier + if (typeof encryptedVerifier !== 'string') { + throw failedDependency(`${input.name} OAuth PKCE state is invalid.`) + } + const token = await input.provider.exchangeCode(code, await input.credentials.openVerifier(encryptedVerifier)) + const identity = input.identity(await input.provider.userInfo(token.accessToken)) + await input.credentials.upsert(identity, token) + return { + type: 'complete', + grant: { + subject: identity.subject, + displayName: identity.displayName, + scopes: intent.scopes, + authorizationDetails: [], + }, + } + }, + } +} + +export function createManagedOAuthCredentialSource(input: { + agentScopes: readonly string[] + provider: ManagedOAuthClient + credentials: ManagedOAuthCredentials + now?: () => number +}) { + const now = input.now ?? Date.now + return async (subject: string) => { + let credential = await input.credentials.credential(subject) + if (credential.expiresAt <= now() + 30_000) { + const refreshed = await input.provider.refresh(credential.refreshToken) + if (await input.credentials.replace(credential, refreshed)) { + credential = { + ...credential, + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? credential.refreshToken, + expiresAt: refreshed.expiresAt, + providerScopes: refreshed.scopes, + credentialVersion: credential.credentialVersion + 1, + } + } else { + credential = await input.credentials.credential(subject) + } + } + return { + authorization: `Bearer ${credential.accessToken}`, + scopes: input.agentScopes, + actorType: 'oauth_delegated_user', + } + } +} diff --git a/src/providers/context7/adapter.ts b/src/providers/context7/adapter.ts index 9f890c7..75bd2d9 100644 --- a/src/providers/context7/adapter.ts +++ b/src/providers/context7/adapter.ts @@ -1,4 +1,5 @@ import type { AdapterModule } from '../../core/adapter.js' +import { createManagedOAuthCredentialSource } from '../../core/managed-oauth.js' import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' import type { Context7AdapterConfig } from './config.js' @@ -33,6 +34,11 @@ export function createContext7Adapter( scopes: [context7AgentScope], }, ] as const + const credential = createManagedOAuthCredentialSource({ + agentScopes: [context7AgentScope], + provider: dependencies.provider, + credentials: dependencies.credentials, + }) return createManagedOpenApiAdapter( { @@ -68,29 +74,7 @@ export function createContext7Adapter( authenticator: dependencies.authenticator, audit: dependencies.audit, ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}), - async credential(subject) { - let credential = await dependencies.credentials.credential(subject) - if (credential.expiresAt <= Date.now() + 30_000) { - const refreshed = await dependencies.provider.refresh(credential.refreshToken) - if (await dependencies.credentials.replace(credential, refreshed)) { - credential = { - ...credential, - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? credential.refreshToken, - expiresAt: refreshed.expiresAt, - providerScopes: refreshed.scopes, - credentialVersion: credential.credentialVersion + 1, - } - } else { - credential = await dependencies.credentials.credential(subject) - } - } - return { - authorization: `Bearer ${credential.accessToken}`, - scopes: [context7AgentScope], - actorType: 'oauth_delegated_user', - } - }, + credential, }, ) } diff --git a/src/providers/context7/oauth.ts b/src/providers/context7/oauth.ts index e61cae7..d800d87 100644 --- a/src/providers/context7/oauth.ts +++ b/src/providers/context7/oauth.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import type { CredentialCipher } from '../../core/credential-cipher.js' -import type { createDynamicOAuthClient, DynamicOAuthToken } from '../../core/dynamic-oauth-client.js' +import type { DynamicOAuthToken } from '../../core/dynamic-oauth-client.js' import type { ExternalProviderAuthorization } from '../../core/external-authorization-server.js' +import { createManagedOAuthExternalAuthorization, type ManagedOAuthClient } from '../../core/managed-oauth.js' import { failedDependency, forbidden } from '../../core/problem.js' const identitySchema = z @@ -16,7 +17,7 @@ const identitySchema = z export const context7AgentScope = 'documentation:read' export const context7ProviderScopes = ['openid', 'profile', 'email', 'offline_access'] as const -export type Context7OAuthClient = ReturnType +export type Context7OAuthClient = ManagedOAuthClient export type Context7Credential = Readonly<{ subject: string displayName: string @@ -141,48 +142,19 @@ export function createContext7ExternalAuthorization(input: { provider: Context7OAuthClient credentials: D1Context7Credentials }): ExternalProviderAuthorization { - return { + return createManagedOAuthExternalAuthorization({ id: 'context7', - resource: `${input.origin}/context7`, - scopes: ['openid', 'profile', 'email', 'offline_access', context7AgentScope], - async validateGrant({ subject }) { - await input.credentials.credential(subject) - return true - }, - async revoke(subject) { - const credential = await input.credentials.credential(subject) - await input.provider.revoke(credential.refreshToken) - await input.credentials.revoke(subject) - }, - async begin({ providerState }) { - const started = await input.provider.authorizationUrl(providerState) + name: 'Context7', + origin: input.origin, + agentScopes: [context7AgentScope], + provider: input.provider, + credentials: input.credentials, + identity(value) { + const identity = identitySchema.parse(value) return { - url: started.url, - stage: 'provider', - data: { verifier: await input.credentials.sealVerifier(started.verifier) }, - } - }, - async complete({ callbackUrl, intent }) { - const code = new URL(callbackUrl).searchParams.get('code') - if (!code) throw failedDependency('Context7 OAuth callback did not include a code.') - const encryptedVerifier = intent.providerData.verifier - if (typeof encryptedVerifier !== 'string') throw failedDependency('Context7 OAuth PKCE state is invalid.') - const token = await input.provider.exchangeCode(code, await input.credentials.openVerifier(encryptedVerifier)) - const identity = identitySchema.parse(await input.provider.userInfo(token.accessToken)) - const resolved = { subject: identity.sub, displayName: identity.name ?? identity.preferred_username ?? identity.email ?? identity.sub, } - await input.credentials.upsert(resolved, token) - return { - type: 'complete', - grant: { - subject: resolved.subject, - displayName: resolved.displayName, - scopes: intent.scopes, - authorizationDetails: [], - }, - } }, - } + }) } diff --git a/src/providers/todoist/adapter.ts b/src/providers/todoist/adapter.ts new file mode 100644 index 0000000..4782c5d --- /dev/null +++ b/src/providers/todoist/adapter.ts @@ -0,0 +1,83 @@ +import type { AdapterModule } from '../../core/adapter.js' +import { + createManagedOAuthCredentialSource, + type ManagedOAuthClient, + type ManagedOAuthCredentials, +} from '../../core/managed-oauth.js' +import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' +import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' +import type { TodoistAdapterConfig } from './config.js' +import { todoistAgentScope } from './oauth.js' +import { todoistOpenApi } from './openapi.js' + +export function createTodoistAdapter( + config: TodoistAdapterConfig, + dependencies: { + authenticator: RealmrootAuthenticator + provider: ManagedOAuthClient + credentials: ManagedOAuthCredentials + audit(record: Record): Promise + fetch?: typeof fetch + }, +): AdapterModule { + const resource = `${config.origin}/todoist` + const issuer = `${config.origin}/oauth/todoist` + const operations = [ + { + operationId: 'listTodoistProjects', + method: 'GET', + path: '/projects', + upstreamPath: '/projects', + scopes: [todoistAgentScope], + }, + { + operationId: 'listTodoistTasks', + method: 'GET', + path: '/tasks', + upstreamPath: '/tasks', + scopes: [todoistAgentScope], + }, + ] as const + + return createManagedOpenApiAdapter( + { + id: 'todoist', + resource, + issuer, + upstreamOrigin: config.todoistApiOrigin, + scopes: { [todoistAgentScope]: 'List active Todoist projects and tasks.' }, + operations, + openapi: todoistOpenApi({ resource, issuer }), + representation: { upstream: 'todoist', operationMode: 'configured-openapi' }, + manifest: { + schemaVersion: '0.1', + provider: 'todoist', + status: 'experimental', + identity: { + level: 'provider-delegated', + visibleInProduct: false, + visibleInAuditLog: false, + attribution: 'audit-only', + }, + actorModes: ['oauth-delegated-user'], + credentialModes: ['adapter-dynamic-public-oauth'], + resourceTypes: ['project', 'task'], + scopes: { [todoistAgentScope]: { providerPermissions: { oauthScope: 'data:read' } } }, + operations, + revocationSignals: ['adapter-local-revocation'], + nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP', 'PROVIDER-REVOCATION'], + retirementCondition: 'Todoist accepts Realmroot Agent identity and proof-bound delegated authority directly.', + }, + }, + { + authenticator: dependencies.authenticator, + audit: dependencies.audit, + ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}), + credential: createManagedOAuthCredentialSource({ + agentScopes: [todoistAgentScope], + provider: dependencies.provider, + credentials: dependencies.credentials, + }), + }, + ) +} diff --git a/src/providers/todoist/config.ts b/src/providers/todoist/config.ts new file mode 100644 index 0000000..f41190e --- /dev/null +++ b/src/providers/todoist/config.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import type { AppConfig } from '../../config.js' + +const environmentSchema = z.object({ + TODOIST_API_ORIGIN: z.url().default('https://api.todoist.com/api/v1'), + TODOIST_AUTHORIZATION_ENDPOINT: z.url().default('https://app.todoist.com/oauth/authorize'), + TODOIST_TOKEN_ENDPOINT: z.url().default('https://api.todoist.com/oauth/access_token'), + TODOIST_REGISTRATION_ENDPOINT: z.url().default('https://api.todoist.com/oauth/register'), + TODOIST_USERINFO_ENDPOINT: z.url().default('https://api.todoist.com/api/v1/user'), + TODOIST_CREDENTIAL_ENCRYPTION_KEY: z.string().trim().min(1).optional(), +}) + +export type TodoistAdapterConfig = AppConfig & { + todoistApiOrigin: string + todoistAuthorizationEndpoint: string + todoistTokenEndpoint: string + todoistRegistrationEndpoint: string + todoistUserInfoEndpoint: string + todoistCredentialEncryptionKey?: string +} + +export function loadTodoistConfig(environment: unknown, config: AppConfig): TodoistAdapterConfig { + const parsed = environmentSchema.parse(environment) + return { + ...config, + todoistApiOrigin: parsed.TODOIST_API_ORIGIN.replace(/\/+$/, ''), + todoistAuthorizationEndpoint: parsed.TODOIST_AUTHORIZATION_ENDPOINT, + todoistTokenEndpoint: parsed.TODOIST_TOKEN_ENDPOINT, + todoistRegistrationEndpoint: parsed.TODOIST_REGISTRATION_ENDPOINT, + todoistUserInfoEndpoint: parsed.TODOIST_USERINFO_ENDPOINT, + ...(parsed.TODOIST_CREDENTIAL_ENCRYPTION_KEY + ? { todoistCredentialEncryptionKey: parsed.TODOIST_CREDENTIAL_ENCRYPTION_KEY } + : {}), + } +} diff --git a/src/providers/todoist/oauth.ts b/src/providers/todoist/oauth.ts new file mode 100644 index 0000000..3d120d7 --- /dev/null +++ b/src/providers/todoist/oauth.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { + createManagedOAuthExternalAuthorization, + type ManagedOAuthClient, + type ManagedOAuthCredentials, +} from '../../core/managed-oauth.js' + +const identitySchema = z + .object({ + id: z.union([z.string().min(1), z.number().int().positive()]), + full_name: z.string().min(1).optional(), + email: z.email().optional(), + }) + .passthrough() + +export const todoistAgentScope = 'tasks:read' +export const todoistProviderScopes = ['data:read'] as const + +export function createTodoistExternalAuthorization(input: { + origin: string + provider: ManagedOAuthClient + credentials: ManagedOAuthCredentials +}) { + return createManagedOAuthExternalAuthorization({ + id: 'todoist', + name: 'Todoist', + origin: input.origin, + agentScopes: [todoistAgentScope], + provider: input.provider, + credentials: input.credentials, + identity(value) { + const identity = identitySchema.parse(value) + const subject = String(identity.id) + return { subject, displayName: identity.full_name ?? identity.email ?? subject } + }, + }) +} diff --git a/src/providers/todoist/openapi.ts b/src/providers/todoist/openapi.ts new file mode 100644 index 0000000..4b9eac6 --- /dev/null +++ b/src/providers/todoist/openapi.ts @@ -0,0 +1,154 @@ +import { openIdConfigurationUrl } from '../../core/external-authorization-server.js' +import { todoistAgentScope } from './oauth.js' + +export function todoistOpenApi(input: { resource: string; issuer: string }) { + const security = [{ todoistTasks: [todoistAgentScope] }] + const cursor = { + name: 'cursor', + in: 'query', + required: false, + description: 'Opaque cursor returned as next_cursor by the previous page.', + schema: { type: 'string', minLength: 1 }, + } + const limit = { + name: 'limit', + in: 'query', + required: false, + description: 'Maximum number of resources to return.', + schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, + } + const optionalId = (name: string, description: string) => ({ + name, + in: 'query', + required: false, + description, + schema: { type: 'string', minLength: 1 }, + }) + const optionalInteger = (name: string, description: string) => ({ + name, + in: 'query', + required: false, + description, + schema: { type: 'integer', minimum: 1 }, + }) + + return { + openapi: '3.1.0', + info: { + title: 'Todoist through Realmroot', + version: '2026-09-04', + description: 'List Todoist projects and active tasks through an Agent-bound, read-only OAuth resource.', + }, + servers: [{ url: input.resource }], + paths: { + '/projects': { + get: { + operationId: 'listTodoistProjects', + summary: 'List active Todoist projects', + security, + parameters: [ + optionalInteger('folder_id', 'Filter projects by folder ID.'), + optionalInteger('workspace_id', 'Filter projects by workspace ID.'), + cursor, + limit, + ], + responses: { + 200: { + description: 'A page of active Todoist projects.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ProjectPage' } } }, + }, + default: { description: 'Todoist response or Realmroot authorization failure.' }, + }, + }, + }, + '/tasks': { + get: { + operationId: 'listTodoistTasks', + summary: 'List active Todoist tasks', + security, + parameters: [ + optionalId('project_id', 'Filter tasks by project ID.'), + optionalId('section_id', 'Filter tasks by section ID.'), + optionalId('parent_id', 'Filter tasks by parent task ID.'), + optionalId('label', 'Filter tasks by label name.'), + optionalId('ids', 'Comma-separated task IDs to retrieve.'), + cursor, + limit, + ], + responses: { + 200: { + description: 'A page of active Todoist tasks.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/TaskPage' } } }, + }, + default: { description: 'Todoist response or Realmroot authorization failure.' }, + }, + }, + }, + }, + components: { + schemas: { + ProjectPage: { + type: 'object', + required: ['results'], + properties: { + results: { type: 'array', items: { $ref: '#/components/schemas/Project' } }, + next_cursor: { type: ['string', 'null'] }, + }, + additionalProperties: true, + }, + Project: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string' }, + parent_id: { type: ['string', 'null'] }, + workspace_id: { type: ['integer', 'null'] }, + color: { type: 'string' }, + is_favorite: { type: 'boolean' }, + is_shared: { type: 'boolean' }, + inbox_project: { type: 'boolean' }, + }, + additionalProperties: true, + }, + TaskPage: { + type: 'object', + required: ['results'], + properties: { + results: { type: 'array', items: { $ref: '#/components/schemas/Task' } }, + next_cursor: { type: ['string', 'null'] }, + }, + additionalProperties: true, + }, + Task: { + type: 'object', + required: ['id', 'content', 'project_id'], + properties: { + id: { type: 'string' }, + content: { type: 'string' }, + description: { type: 'string' }, + project_id: { type: 'string' }, + section_id: { type: ['string', 'null'] }, + parent_id: { type: ['string', 'null'] }, + labels: { type: 'array', items: { type: 'string' } }, + priority: { type: 'integer', minimum: 1, maximum: 4 }, + checked: { type: 'boolean' }, + due: { type: ['object', 'null'], additionalProperties: true }, + deadline: { type: ['object', 'null'], additionalProperties: true }, + }, + additionalProperties: true, + }, + }, + securitySchemes: { + todoistTasks: { + type: 'openIdConnect', + openIdConnectUrl: openIdConfigurationUrl(input.issuer), + 'x-dpop-required': true, + description: 'Realmroot Agent credential with approved read-only Todoist access.', + }, + }, + }, + 'x-provider-upstream': 'https://api.todoist.com/api/v1', + } +} diff --git a/src/worker.ts b/src/worker.ts index 5afcac2..ae1a6a4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,6 +6,7 @@ import { createCredentialCipher } from './core/credential-cipher.js' import { createDynamicOAuthClient, D1DynamicOAuthRegistrationStore } from './core/dynamic-oauth-client.js' import { createExternalAuthorizationServer } from './core/external-authorization-server.js' import { D1ExternalOAuthStore } from './core/external-oauth-store.js' +import { D1ManagedOAuthCredentials } from './core/managed-oauth.js' import { createCloudflareAdapter } from './providers/cloudflare/adapter.js' import { loadCloudflareConfig } from './providers/cloudflare/config.js' import { cloudflareManifest } from './providers/cloudflare/manifest.js' @@ -34,6 +35,9 @@ import { D1LinearConnections } from './providers/linear/connections.js' import { createLinearCredentialCipher } from './providers/linear/credentials.js' import { createLinearExternalAuthorization } from './providers/linear/external-authorization.js' import { linearScopes } from './providers/linear/scopes.js' +import { createTodoistAdapter } from './providers/todoist/adapter.js' +import { loadTodoistConfig } from './providers/todoist/config.js' +import { createTodoistExternalAuthorization, todoistProviderScopes } from './providers/todoist/oauth.js' import { D1RuntimeState } from './storage/d1-runtime-state.js' export default { @@ -45,6 +49,7 @@ export default { const cloudflareConfig = loadCloudflareConfig(env, config) const context7Config = loadContext7Config(env, config) const linearConfig = loadLinearConfig(env, config) + const todoistConfig = loadTodoistConfig(env, config) const state = new D1RuntimeState(env.DB) const oauthStore = new D1ExternalOAuthStore(env.DB) const signingPrivateJwk = config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined @@ -184,7 +189,13 @@ export default { const context7Provider = createDynamicOAuthClient({ providerId: 'context7', clientName: 'Realmroot Context7 Adapter', - issuer: context7Config.context7OAuthIssuer, + endpoints: { + authorization: `${context7Config.context7OAuthIssuer}/oauth/authorize`, + registration: `${context7Config.context7OAuthIssuer}/oauth/register`, + token: `${context7Config.context7OAuthIssuer}/oauth/token`, + userInfo: `${context7Config.context7OAuthIssuer}/oauth/userinfo`, + revocation: `${context7Config.context7OAuthIssuer}/oauth/token/revoke`, + }, redirectUri: `${config.origin}/oauth/context7/provider/callback`, scopes: context7ProviderScopes, registrationStore: new D1DynamicOAuthRegistrationStore(env.DB), @@ -212,6 +223,51 @@ export default { }), ) } + if (todoistConfig.todoistCredentialEncryptionKey) { + if (!signingPrivateJwk) throw new Error('Todoist external authorization is not configured.') + const todoistCredentials = new D1ManagedOAuthCredentials( + 'todoist', + 'Todoist', + env.DB, + createCredentialCipher(todoistConfig.todoistCredentialEncryptionKey), + ) + const todoistProvider = createDynamicOAuthClient({ + providerId: 'todoist', + clientName: 'Realmroot Todoist Adapter', + endpoints: { + authorization: todoistConfig.todoistAuthorizationEndpoint, + registration: todoistConfig.todoistRegistrationEndpoint, + token: todoistConfig.todoistTokenEndpoint, + userInfo: todoistConfig.todoistUserInfoEndpoint, + }, + redirectUri: `${config.origin}/oauth/todoist/provider/callback`, + scopes: todoistProviderScopes, + authorizationScopeSeparator: ',', + registrationStore: new D1DynamicOAuthRegistrationStore(env.DB), + fetcher: fetch, + }) + const todoistAuthorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: createTodoistExternalAuthorization({ + origin: config.origin, + provider: todoistProvider, + credentials: todoistCredentials, + }), + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + todoistAuthorization, + createTodoistAdapter(todoistConfig, { + authenticator: todoistAuthorization.authenticator, + provider: todoistProvider, + credentials: todoistCredentials, + audit: (record) => state.recordAudit(record), + fetch, + }), + ) + } const app = createApp(adapters) return tracing.enterSpan('adapter.router.dispatch', () => app.fetch(request, env, executionContext)) }) diff --git a/test/core/dynamic-oauth-client.test.ts b/test/core/dynamic-oauth-client.test.ts index fb00563..0c28657 100644 --- a/test/core/dynamic-oauth-client.test.ts +++ b/test/core/dynamic-oauth-client.test.ts @@ -33,7 +33,13 @@ describe('Dynamic OAuth client', () => { const client = createDynamicOAuthClient({ providerId: 'context7', clientName: 'Realmroot Context7 Adapter', - issuer: 'https://clerk.context7.com', + endpoints: { + authorization: 'https://clerk.context7.com/oauth/authorize', + registration: 'https://clerk.context7.com/oauth/register', + token: 'https://clerk.context7.com/oauth/token', + userInfo: 'https://clerk.context7.com/oauth/userinfo', + revocation: 'https://clerk.context7.com/oauth/token/revoke', + }, redirectUri: 'https://adapter.example/oauth/context7/provider/callback', scopes: ['openid', 'offline_access'], registrationStore: store, @@ -62,4 +68,52 @@ describe('Dynamic OAuth client', () => { expect(String(tokenInit.body)).toContain(`code_verifier=${first.verifier}`) expect(String(tokenInit.body)).toContain('client_id=dynamic-client') }) + + it('[spec: todoist-adapter/todoist-provider-oauth] supports split endpoints and comma-separated authorization scopes', async () => { + const store: DynamicOAuthRegistrationStore = { + clientId: vi.fn(async () => null), + saveClientId: vi.fn(async (_providerId, value) => value), + } + const fetcher = vi.fn(async (request: string | URL | Request) => { + const url = new URL(request instanceof Request ? request.url : request) + if (url.pathname === '/oauth/register') return Response.json({ client_id: 'tdd_dynamic' }, { status: 201 }) + if (url.pathname === '/oauth/access_token') { + return Response.json({ + access_token: 'todoist-access', + refresh_token: 'todoist-refresh', + expires_in: 3600, + scope: 'data:read,user:read', + }) + } + if (url.pathname === '/api/v1/user') return Response.json({ id: 'user-1' }) + throw new Error(`Unexpected request ${url}`) + }) + const client = createDynamicOAuthClient({ + providerId: 'todoist', + clientName: 'Realmroot Todoist Adapter', + endpoints: { + authorization: 'https://app.todoist.com/oauth/authorize', + registration: 'https://api.todoist.com/oauth/register', + token: 'https://api.todoist.com/oauth/access_token', + userInfo: 'https://api.todoist.com/api/v1/user', + }, + redirectUri: 'https://adapter.example/oauth/todoist/provider/callback', + scopes: ['data:read', 'user:read'], + authorizationScopeSeparator: ',', + registrationStore: store, + fetcher: fetcher as typeof fetch, + now: () => 1_000, + }) + + const started = await client.authorizationUrl('todoist-state') + const authorizationUrl = new URL(started.url) + expect(authorizationUrl.origin).toBe('https://app.todoist.com') + expect(authorizationUrl.searchParams.get('scope')).toBe('data:read,user:read') + expect(client.revoke).toBeUndefined() + await expect(client.exchangeCode('todoist-code', started.verifier)).resolves.toMatchObject({ + accessToken: 'todoist-access', + refreshToken: 'todoist-refresh', + scopes: ['data:read', 'user:read'], + }) + }) }) diff --git a/test/integration/managed-oauth-credentials.test.ts b/test/integration/managed-oauth-credentials.test.ts new file mode 100644 index 0000000..029cf37 --- /dev/null +++ b/test/integration/managed-oauth-credentials.test.ts @@ -0,0 +1,40 @@ +import { env } from 'cloudflare:test' +import { describe, expect, it } from 'vitest' +import { createCredentialCipher } from '../../src/core/credential-cipher.js' +import { D1ManagedOAuthCredentials } from '../../src/core/managed-oauth.js' + +describe('Managed OAuth D1 credentials', () => { + it('[spec: todoist-adapter/todoist-provider-oauth] isolates providers and encrypts reusable credentials', async () => { + const credentials = new D1ManagedOAuthCredentials( + 'todoist', + 'Todoist', + env.DB, + createCredentialCipher('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), + ) + await credentials.upsert( + { subject: 'todoist-user', displayName: 'Todo User' }, + { + accessToken: 'plain-access-token', + refreshToken: 'plain-refresh-token', + expiresAt: 10_000, + scopes: ['data:read'], + }, + ) + const row = await env.DB.prepare( + `SELECT provider_id AS providerId, access_token_ciphertext AS accessToken, + refresh_token_ciphertext AS refreshToken + FROM managed_oauth_credential WHERE provider_id = ? AND subject = ?`, + ) + .bind('todoist', 'todoist-user') + .first<{ providerId: string; accessToken: string; refreshToken: string }>() + expect(row?.providerId).toBe('todoist') + expect(row?.accessToken).not.toContain('plain-access-token') + expect(row?.refreshToken).not.toContain('plain-refresh-token') + await expect(credentials.credential('todoist-user')).resolves.toMatchObject({ + accessToken: 'plain-access-token', + refreshToken: 'plain-refresh-token', + displayName: 'Todo User', + providerScopes: ['data:read'], + }) + }) +}) diff --git a/test/providers/todoist-oauth.test.ts b/test/providers/todoist-oauth.test.ts new file mode 100644 index 0000000..0e29c21 --- /dev/null +++ b/test/providers/todoist-oauth.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExternalOAuthIntent } from '../../src/core/external-oauth-store.js' +import type { ManagedOAuthClient, ManagedOAuthCredentials } from '../../src/core/managed-oauth.js' +import { createTodoistExternalAuthorization } from '../../src/providers/todoist/oauth.js' + +describe('Todoist external authorization', () => { + it('[spec: todoist-adapter/todoist-provider-oauth] resolves identity and supports local-only revocation', async () => { + const provider: ManagedOAuthClient = { + authorizationUrl: vi.fn(async () => ({ url: 'https://app.todoist.com/oauth/authorize', verifier: 'verifier' })), + exchangeCode: vi.fn(async () => ({ + accessToken: 'todoist-access', + refreshToken: 'todoist-refresh', + expiresAt: Date.now() + 60_000, + scopes: ['data:read'], + })), + refresh: vi.fn(), + userInfo: vi.fn(async () => ({ id: 'todoist-user-1', full_name: 'Todo User' })), + } + const credentials: ManagedOAuthCredentials = { + sealVerifier: vi.fn(async (value) => `sealed:${value}`), + openVerifier: vi.fn(async () => 'verifier'), + upsert: vi.fn(async () => {}), + credential: vi.fn(async () => ({ + subject: 'todoist-user-1', + displayName: 'Todo User', + accessToken: 'todoist-access', + refreshToken: 'todoist-refresh', + expiresAt: Date.now() + 60_000, + providerScopes: ['data:read'], + credentialVersion: 1, + })), + replace: vi.fn(async () => true), + revoke: vi.fn(async () => {}), + } + const authorization = createTodoistExternalAuthorization({ + origin: 'https://adapter.example', + provider, + credentials, + }) + const intent: ExternalOAuthIntent = { + id: 'intent-1', + providerId: 'todoist', + clientId: 'realmroot', + redirectUri: 'https://id.example/callback', + realmrootState: 'realmroot-state', + scopes: ['openid', 'offline_access', 'tasks:read'], + authorizationDetails: [], + codeChallenge: 'challenge', + providerStage: 'provider', + providerData: { verifier: 'sealed:verifier' }, + expiresAt: Date.now() + 60_000, + } + + await expect( + authorization.complete({ + callbackUrl: 'https://adapter.example/oauth/todoist/provider/callback?code=todoist-code', + intent, + nextProviderState: () => 'unused', + }), + ).resolves.toMatchObject({ + type: 'complete', + grant: { subject: 'todoist-user-1', displayName: 'Todo User', scopes: intent.scopes }, + }) + expect(credentials.upsert).toHaveBeenCalledWith( + { subject: 'todoist-user-1', displayName: 'Todo User' }, + expect.objectContaining({ accessToken: 'todoist-access' }), + ) + + await authorization.revoke?.('todoist-user-1') + expect(credentials.revoke).toHaveBeenCalledWith('todoist-user-1') + }) +}) diff --git a/test/providers/todoist-openapi.test.ts b/test/providers/todoist-openapi.test.ts new file mode 100644 index 0000000..e9997bb --- /dev/null +++ b/test/providers/todoist-openapi.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { todoistOpenApi } from '../../src/providers/todoist/openapi.js' + +describe('Todoist OpenAPI', () => { + it('[spec: todoist-adapter/todoist-contract] publishes read-only resources with explicit OAuth authority', () => { + const document = todoistOpenApi({ + resource: 'https://adapter.example/todoist', + issuer: 'https://adapter.example/oauth/todoist', + }) + expect(Object.keys(document.paths)).toEqual(['/projects', '/tasks']) + for (const path of Object.values(document.paths)) { + expect(path.get.security).toEqual([{ todoistTasks: ['tasks:read'] }]) + expect(path.get.operationId).toMatch(/^list/) + } + expect(document.components.securitySchemes.todoistTasks).toMatchObject({ + type: 'openIdConnect', + openIdConnectUrl: 'https://adapter.example/.well-known/openid-configuration/oauth/todoist', + 'x-dpop-required': true, + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 4396da5..dbb6852 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,6 +38,11 @@ export default defineConfig({ LINEAR_CREDENTIAL_ENCRYPTION_KEY: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', CONTEXT7_API_ORIGIN: 'https://context7.com/api', CONTEXT7_OAUTH_ISSUER: 'https://clerk.context7.com', + TODOIST_API_ORIGIN: 'https://api.todoist.com/api/v1', + TODOIST_AUTHORIZATION_ENDPOINT: 'https://app.todoist.com/oauth/authorize', + TODOIST_TOKEN_ENDPOINT: 'https://api.todoist.com/oauth/access_token', + TODOIST_REGISTRATION_ENDPOINT: 'https://api.todoist.com/oauth/register', + TODOIST_USERINFO_ENDPOINT: 'https://api.todoist.com/api/v1/user', }, }, })), diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index f35fca4..2635ced 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 5057dec8cc23a8e43b9eb3ad422511f7) +// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 8a6e409f7418611a7b81357c8708bc8b) // Runtime types generated with workerd@1.20260801.1 2026-08-08 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -14,6 +14,11 @@ interface __BaseEnv_Env { CLOUDFLARE_AUTHORIZATION_ORIGIN: string; CONTEXT7_API_ORIGIN: string; CONTEXT7_OAUTH_ISSUER: string; + TODOIST_API_ORIGIN: string; + TODOIST_AUTHORIZATION_ENDPOINT: string; + TODOIST_TOKEN_ENDPOINT: string; + TODOIST_REGISTRATION_ENDPOINT: string; + TODOIST_USERINFO_ENDPOINT: string; GITHUB_APP_ID: string; GITHUB_PRIVATE_KEY: string; GITHUB_CLIENT_ID: string; @@ -28,6 +33,7 @@ interface __BaseEnv_Env { CLOUDFLARE_CLIENT_SECRET: string; CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY: string; CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: string; + TODOIST_CREDENTIAL_ENCRYPTION_KEY: string; } declare namespace Cloudflare { interface GlobalProps { @@ -40,7 +46,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index bc8d765..f0ca823 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -18,7 +18,12 @@ "CLOUDFLARE_API_ORIGIN": "https://api.cloudflare.com/client/v4", "CLOUDFLARE_AUTHORIZATION_ORIGIN": "https://dash.cloudflare.com", "CONTEXT7_API_ORIGIN": "https://context7.com/api", - "CONTEXT7_OAUTH_ISSUER": "https://clerk.context7.com" + "CONTEXT7_OAUTH_ISSUER": "https://clerk.context7.com", + "TODOIST_API_ORIGIN": "https://api.todoist.com/api/v1", + "TODOIST_AUTHORIZATION_ENDPOINT": "https://app.todoist.com/oauth/authorize", + "TODOIST_TOKEN_ENDPOINT": "https://api.todoist.com/oauth/access_token", + "TODOIST_REGISTRATION_ENDPOINT": "https://api.todoist.com/oauth/register", + "TODOIST_USERINFO_ENDPOINT": "https://api.todoist.com/api/v1/user" }, "assets": { "directory": "./public" }, "secrets": { @@ -36,7 +41,8 @@ "CLOUDFLARE_CLIENT_ID", "CLOUDFLARE_CLIENT_SECRET", "CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY", - "CONTEXT7_CREDENTIAL_ENCRYPTION_KEY" + "CONTEXT7_CREDENTIAL_ENCRYPTION_KEY", + "TODOIST_CREDENTIAL_ENCRYPTION_KEY" ] }, "d1_databases": [ From 78e529d9574387fb5853e7c3291f112b8d9be85f Mon Sep 17 00:00:00 2001 From: saltbo Date: Fri, 4 Sep 2026 15:06:16 -0400 Subject: [PATCH 2/2] fix(oauth): enforce configured provider scopes --- src/core/managed-oauth.ts | 9 +++++++-- src/providers/context7/adapter.ts | 8 +++++++- src/providers/context7/oauth.ts | 1 + src/providers/todoist/adapter.ts | 3 ++- src/providers/todoist/oauth.ts | 1 + test/providers/todoist-oauth.test.ts | 3 +++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/core/managed-oauth.ts b/src/core/managed-oauth.ts index 39bfc4f..7e7843f 100644 --- a/src/core/managed-oauth.ts +++ b/src/core/managed-oauth.ts @@ -151,6 +151,7 @@ export function createManagedOAuthExternalAuthorization(input: { name: string origin: string agentScopes: readonly string[] + providerScopes: readonly string[] provider: ManagedOAuthClient credentials: ManagedOAuthCredentials identity(value: unknown): { subject: string; displayName: string } @@ -160,8 +161,8 @@ export function createManagedOAuthExternalAuthorization(input: { resource: `${input.origin}/${input.id}`, scopes: ['openid', 'profile', 'email', 'offline_access', ...input.agentScopes], async validateGrant({ subject }) { - await input.credentials.credential(subject) - return true + const credential = await input.credentials.credential(subject) + return input.providerScopes.every((scope) => credential.providerScopes.includes(scope)) }, async revoke(subject) { const credential = await input.credentials.credential(subject) @@ -201,6 +202,7 @@ export function createManagedOAuthExternalAuthorization(input: { export function createManagedOAuthCredentialSource(input: { agentScopes: readonly string[] + providerScopes: readonly string[] provider: ManagedOAuthClient credentials: ManagedOAuthCredentials now?: () => number @@ -223,6 +225,9 @@ export function createManagedOAuthCredentialSource(input: { credential = await input.credentials.credential(subject) } } + if (!input.providerScopes.every((scope) => credential.providerScopes.includes(scope))) { + throw forbidden('The upstream OAuth grant does not contain the configured provider scopes.') + } return { authorization: `Bearer ${credential.accessToken}`, scopes: input.agentScopes, diff --git a/src/providers/context7/adapter.ts b/src/providers/context7/adapter.ts index 75bd2d9..2bcf38c 100644 --- a/src/providers/context7/adapter.ts +++ b/src/providers/context7/adapter.ts @@ -3,7 +3,12 @@ import { createManagedOAuthCredentialSource } from '../../core/managed-oauth.js' import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' import type { Context7AdapterConfig } from './config.js' -import { type Context7OAuthClient, context7AgentScope, type D1Context7Credentials } from './oauth.js' +import { + type Context7OAuthClient, + context7AgentScope, + context7ProviderScopes, + type D1Context7Credentials, +} from './oauth.js' import { context7OpenApi } from './openapi.js' export function createContext7Adapter( @@ -36,6 +41,7 @@ export function createContext7Adapter( ] as const const credential = createManagedOAuthCredentialSource({ agentScopes: [context7AgentScope], + providerScopes: context7ProviderScopes, provider: dependencies.provider, credentials: dependencies.credentials, }) diff --git a/src/providers/context7/oauth.ts b/src/providers/context7/oauth.ts index d800d87..3a092da 100644 --- a/src/providers/context7/oauth.ts +++ b/src/providers/context7/oauth.ts @@ -147,6 +147,7 @@ export function createContext7ExternalAuthorization(input: { name: 'Context7', origin: input.origin, agentScopes: [context7AgentScope], + providerScopes: context7ProviderScopes, provider: input.provider, credentials: input.credentials, identity(value) { diff --git a/src/providers/todoist/adapter.ts b/src/providers/todoist/adapter.ts index 4782c5d..f270bb4 100644 --- a/src/providers/todoist/adapter.ts +++ b/src/providers/todoist/adapter.ts @@ -7,7 +7,7 @@ import { import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' import type { TodoistAdapterConfig } from './config.js' -import { todoistAgentScope } from './oauth.js' +import { todoistAgentScope, todoistProviderScopes } from './oauth.js' import { todoistOpenApi } from './openapi.js' export function createTodoistAdapter( @@ -75,6 +75,7 @@ export function createTodoistAdapter( ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}), credential: createManagedOAuthCredentialSource({ agentScopes: [todoistAgentScope], + providerScopes: todoistProviderScopes, provider: dependencies.provider, credentials: dependencies.credentials, }), diff --git a/src/providers/todoist/oauth.ts b/src/providers/todoist/oauth.ts index 3d120d7..b4f857a 100644 --- a/src/providers/todoist/oauth.ts +++ b/src/providers/todoist/oauth.ts @@ -26,6 +26,7 @@ export function createTodoistExternalAuthorization(input: { name: 'Todoist', origin: input.origin, agentScopes: [todoistAgentScope], + providerScopes: todoistProviderScopes, provider: input.provider, credentials: input.credentials, identity(value) { diff --git a/test/providers/todoist-oauth.test.ts b/test/providers/todoist-oauth.test.ts index 0e29c21..6b51625 100644 --- a/test/providers/todoist-oauth.test.ts +++ b/test/providers/todoist-oauth.test.ts @@ -37,6 +37,9 @@ describe('Todoist external authorization', () => { provider, credentials, }) + await expect( + authorization.validateGrant?.({ subject: 'todoist-user-1', scopes: ['tasks:read'], authorizationDetails: [] }), + ).resolves.toBe(true) const intent: ExternalOAuthIntent = { id: 'intent-1', providerId: 'todoist',