diff --git a/migrations/0012_unify_managed_oauth_credentials.sql b/migrations/0012_unify_managed_oauth_credentials.sql new file mode 100644 index 0000000..581e52d --- /dev/null +++ b/migrations/0012_unify_managed_oauth_credentials.sql @@ -0,0 +1,17 @@ +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) +SELECT 'context7', subject, display_name, access_token_ciphertext, refresh_token_ciphertext, + token_expires_at, provider_scope_json, credential_version, updated_at +FROM context7_external_credential +WHERE true +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 = excluded.credential_version, + updated_at = excluded.updated_at; + +DROP TABLE context7_external_credential; diff --git a/src/providers/context7/adapter.ts b/src/providers/context7/adapter.ts index 2bcf38c..681e1fd 100644 --- a/src/providers/context7/adapter.ts +++ b/src/providers/context7/adapter.ts @@ -1,14 +1,9 @@ import type { AdapterModule } from '../../core/adapter.js' -import { createManagedOAuthCredentialSource } from '../../core/managed-oauth.js' +import { createManagedOAuthCredentialSource, 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 { Context7AdapterConfig } from './config.js' -import { - type Context7OAuthClient, - context7AgentScope, - context7ProviderScopes, - type D1Context7Credentials, -} from './oauth.js' +import { type Context7OAuthClient, context7AgentScope, context7ProviderScopes } from './oauth.js' import { context7OpenApi } from './openapi.js' export function createContext7Adapter( @@ -16,7 +11,7 @@ export function createContext7Adapter( dependencies: { authenticator: RealmrootAuthenticator provider: Context7OAuthClient - credentials: D1Context7Credentials + credentials: ManagedOAuthCredentials audit(record: Record): Promise fetch?: typeof fetch }, diff --git a/src/providers/context7/oauth.ts b/src/providers/context7/oauth.ts index 3a092da..167cb11 100644 --- a/src/providers/context7/oauth.ts +++ b/src/providers/context7/oauth.ts @@ -1,9 +1,10 @@ import { z } from 'zod' -import type { CredentialCipher } from '../../core/credential-cipher.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' +import { + createManagedOAuthExternalAuthorization, + type ManagedOAuthClient, + type ManagedOAuthCredentials, +} from '../../core/managed-oauth.js' const identitySchema = z .object({ @@ -18,129 +19,11 @@ export const context7AgentScope = 'documentation:read' export const context7ProviderScopes = ['openid', 'profile', 'email', 'offline_access'] as const export type Context7OAuthClient = ManagedOAuthClient -export type Context7Credential = Readonly<{ - subject: string - displayName: string - accessToken: string - refreshToken: string - expiresAt: number - providerScopes: readonly string[] - credentialVersion: number -}> - -export class D1Context7Credentials { - constructor( - private readonly db: D1Database, - private readonly cipher: CredentialCipher, - ) {} - - sealVerifier(verifier: string) { - return this.cipher.seal(verifier, 'context7:oauth-intent:pkce') - } - - openVerifier(verifier: string) { - return this.cipher.open(verifier, 'context7:oauth-intent:pkce') - } - - async upsert(identity: { subject: string; displayName: string }, token: DynamicOAuthToken) { - if (!token.refreshToken) throw failedDependency('Context7 did not issue the required refresh token.') - const context = `context7:${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 context7_external_credential - (subject, display_name, access_token_ciphertext, refresh_token_ciphertext, token_expires_at, - provider_scope_json, credential_version, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?) - ON CONFLICT(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 = context7_external_credential.credential_version + 1, - updated_at = excluded.updated_at`, - ) - .bind( - 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 context7_external_credential WHERE subject = ?`, - ) - .bind(subject) - .first<{ - subject: string - displayName: string - accessToken: string - refreshToken: string - expiresAt: number - providerScopesJson: string - credentialVersion: number - }>() - if (!row) throw forbidden('Active Context7 authorization is required.') - const context = `context7:${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: Context7Credential, token: DynamicOAuthToken) { - const refreshTokenValue = token.refreshToken ?? credential.refreshToken - const context = `context7:${credential.subject}` - const [accessToken, refreshToken] = await Promise.all([ - this.cipher.seal(token.accessToken, `${context}:access`), - this.cipher.seal(refreshTokenValue, `${context}:refresh`), - ]) - const result = await this.db - .prepare( - `UPDATE context7_external_credential SET access_token_ciphertext = ?, refresh_token_ciphertext = ?, - token_expires_at = ?, provider_scope_json = ?, credential_version = credential_version + 1, updated_at = ? - WHERE subject = ? AND credential_version = ?`, - ) - .bind( - accessToken, - refreshToken, - token.expiresAt, - JSON.stringify(token.scopes), - Date.now(), - credential.subject, - credential.credentialVersion, - ) - .run() - return result.meta.changes === 1 - } - - async revoke(subject: string) { - await this.db.prepare('DELETE FROM context7_external_credential WHERE subject = ?').bind(subject).run() - } -} export function createContext7ExternalAuthorization(input: { origin: string provider: Context7OAuthClient - credentials: D1Context7Credentials + credentials: ManagedOAuthCredentials }): ExternalProviderAuthorization { return createManagedOAuthExternalAuthorization({ id: 'context7', diff --git a/src/worker.ts b/src/worker.ts index 55685c5..e55edde 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -17,11 +17,7 @@ import { } from './providers/cloudflare/oauth.js' import { createContext7Adapter } from './providers/context7/adapter.js' import { loadContext7Config } from './providers/context7/config.js' -import { - context7ProviderScopes, - createContext7ExternalAuthorization, - D1Context7Credentials, -} from './providers/context7/oauth.js' +import { context7ProviderScopes, createContext7ExternalAuthorization } from './providers/context7/oauth.js' import { createGitHubAdapter } from './providers/github/adapter.js' import { createGitHubConnectionProvider, createGitHubProvider } from './providers/github/client.js' import { loadGitHubConfig } from './providers/github/config.js' @@ -182,7 +178,9 @@ export default { } if (context7Config.context7CredentialEncryptionKey) { if (!signingPrivateJwk) throw new Error('Context7 external authorization is not configured.') - const context7Credentials = new D1Context7Credentials( + const context7Credentials = new D1ManagedOAuthCredentials( + 'context7', + 'Context7', env.DB, createCredentialCipher(context7Config.context7CredentialEncryptionKey), ) diff --git a/test/integration/context7-credentials.test.ts b/test/integration/context7-credentials.test.ts index e26af46..5233de3 100644 --- a/test/integration/context7-credentials.test.ts +++ b/test/integration/context7-credentials.test.ts @@ -2,7 +2,7 @@ import { env } from 'cloudflare:test' import { describe, expect, it } from 'vitest' import { createCredentialCipher } from '../../src/core/credential-cipher.js' import { D1DynamicOAuthRegistrationStore } from '../../src/core/dynamic-oauth-client.js' -import { D1Context7Credentials } from '../../src/providers/context7/oauth.js' +import { D1ManagedOAuthCredentials } from '../../src/core/managed-oauth.js' describe('Context7 D1 state', () => { it('[spec: context7-adapter/context7-provider-oauth] persists one dynamic client and encrypts provider credentials', async () => { @@ -10,7 +10,9 @@ describe('Context7 D1 state', () => { await expect(registrations.saveClientId('context7', 'client-first')).resolves.toBe('client-first') await expect(registrations.saveClientId('context7', 'client-racing')).resolves.toBe('client-first') - const credentials = new D1Context7Credentials( + const credentials = new D1ManagedOAuthCredentials( + 'context7', + 'Context7', env.DB, createCredentialCipher('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), ) @@ -25,9 +27,9 @@ describe('Context7 D1 state', () => { ) const row = await env.DB.prepare( `SELECT access_token_ciphertext AS accessToken, refresh_token_ciphertext AS refreshToken - FROM context7_external_credential WHERE subject = ?`, + FROM managed_oauth_credential WHERE provider_id = ? AND subject = ?`, ) - .bind('context7-user') + .bind('context7', 'context7-user') .first<{ accessToken: string; refreshToken: string }>() expect(row?.accessToken).not.toContain('plain-access-token') expect(row?.refreshToken).not.toContain('plain-refresh-token') diff --git a/test/integration/provider-connection-migration.test.ts b/test/integration/provider-connection-migration.test.ts index e2b8346..845f463 100644 --- a/test/integration/provider-connection-migration.test.ts +++ b/test/integration/provider-connection-migration.test.ts @@ -177,5 +177,53 @@ describe('Provider connection migration', () => { "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('external_oauth_client', 'external_oauth_intent', 'external_oauth_code', 'external_oauth_refresh', 'external_oauth_access', 'cloudflare_external_credential')", ).first(), ).resolves.toEqual({ count: 6 }) + + const managedOAuthBaseline = env.TEST_MIGRATIONS.slice(8, 11) + const unifiedManagedOAuthCredentials = env.TEST_MIGRATIONS.slice(11, 12) + expect(managedOAuthBaseline).toHaveLength(3) + expect(unifiedManagedOAuthCredentials).toHaveLength(1) + await applyD1Migrations(env.MIGRATION_DB, managedOAuthBaseline) + await env.MIGRATION_DB.prepare( + `INSERT INTO context7_external_credential + (subject, display_name, access_token_ciphertext, refresh_token_ciphertext, token_expires_at, + provider_scope_json, credential_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + 'context7-user', + 'Context User', + 'sealed-access', + 'sealed-refresh', + 10_000, + '["openid","offline_access"]', + 3, + now, + ) + .run() + + await applyD1Migrations(env.MIGRATION_DB, unifiedManagedOAuthCredentials) + await expect( + env.MIGRATION_DB.prepare( + `SELECT provider_id AS providerId, subject, display_name AS displayName, + access_token_ciphertext AS accessToken, refresh_token_ciphertext AS refreshToken, + credential_version AS credentialVersion + FROM managed_oauth_credential WHERE provider_id = ? AND subject = ?`, + ) + .bind('context7', 'context7-user') + .first(), + ).resolves.toEqual({ + providerId: 'context7', + subject: 'context7-user', + displayName: 'Context User', + accessToken: 'sealed-access', + refreshToken: 'sealed-refresh', + credentialVersion: 3, + }) + + await expect( + env.MIGRATION_DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'context7_external_credential'", + ).first(), + ).resolves.toBeNull() }) })