Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions migrations/0012_unify_managed_oauth_credentials.sql
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 3 additions & 8 deletions src/providers/context7/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
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(
config: Context7AdapterConfig,
dependencies: {
authenticator: RealmrootAuthenticator
provider: Context7OAuthClient
credentials: D1Context7Credentials
credentials: ManagedOAuthCredentials
audit(record: Record<string, unknown>): Promise<void>
fetch?: typeof fetch
},
Expand Down
129 changes: 6 additions & 123 deletions src/providers/context7/oauth.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<Context7Credential> {
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',
Expand Down
10 changes: 4 additions & 6 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
)
Expand Down
10 changes: 6 additions & 4 deletions test/integration/context7-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ 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 () => {
const registrations = new D1DynamicOAuthRegistrationStore(env.DB)
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'),
)
Expand All @@ -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')
Expand Down
48 changes: 48 additions & 0 deletions test/integration/provider-connection-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})