diff --git a/server/apps/auth/src/auth.ts b/server/apps/auth/src/auth.ts index e6ffa0940..8caad3464 100644 --- a/server/apps/auth/src/auth.ts +++ b/server/apps/auth/src/auth.ts @@ -7,6 +7,7 @@ import type { EmailService } from './email' import type { AuthEnv } from './env' import type { AuthMetrics } from './otel' import type { ResourceApi } from './resource-api' +import type { SocialAuthorizationRevoker } from './social-authorization' import { Buffer } from 'node:buffer' @@ -18,13 +19,13 @@ import { createAuthMiddleware } from 'better-auth/api' import { deleteSessionCookie } from 'better-auth/cookies' import { admin, bearer, jwt, magicLink } from 'better-auth/plugins' import { eq } from 'drizzle-orm' -import { importPKCS8, SignJWT } from 'jose' import * as authSchema from '@proj-airi/auth-shared' import { ApiError } from './error' import { oidcJwtBearer } from './oidc-jwt-bearer' import { getAuthTrustedOrigins, getTrustedOrigin } from './origin' +import { createAppleClientSecret, createSocialAuthorizationRevoker } from './social-authorization' import { steam } from './steam' const logger = useLogger('auth').useGlobalConfig() @@ -122,17 +123,7 @@ function createAppleProviderConfig( return { apple: async () => { - const key = await importPKCS8(env.AUTH_APPLE_PRIVATE_KEY_PEM, 'ES256') - const issuedAt = Math.floor(Date.now() / 1000) - const clientSecret = await new SignJWT({}) - .setProtectedHeader({ alg: 'ES256', kid: env.AUTH_APPLE_KEY_ID }) - .setIssuer(env.AUTH_APPLE_TEAM_ID) - .setSubject(env.AUTH_APPLE_CLIENT_ID) - .setAudience('https://appleid.apple.com') - .setIssuedAt(issuedAt) - // Apple caps client-secret JWT validity at six months. - .setExpirationTime(issuedAt + 180 * 24 * 60 * 60) - .sign(key) + const clientSecret = await createAppleClientSecret(env) return { clientId: env.AUTH_APPLE_CLIENT_ID, @@ -442,6 +433,7 @@ export function createAuth( email?: EmailService, metrics?: AuthMetrics | null, resourceApi?: ResourceApi, + socialAuthorization: SocialAuthorizationRevoker = createSocialAuthorizationRevoker(db, env), ): AuthInstance { const auth = betterAuth({ secret: env.BETTER_AUTH_SECRET, @@ -587,14 +579,14 @@ export function createAuth( // Two-step deletion: POST /api/auth/delete-user with an authenticated // session triggers `sendDeleteAccountVerification`; clicking the link // hits GET /api/auth/delete-user/callback?token=..., which validates - // and calls `beforeDelete` BEFORE `internalAdapter.deleteUser`. Throw - // from `beforeDelete` to abort: the user row stays put, the - // verification token has already been consumed (single-use) so the - // user must re-initiate. Soft-delete handlers must be idempotent - // because retrying a partial deletion re-runs already-completed - // handlers as no-ops. + // and calls `beforeDelete` BEFORE `internalAdapter.deleteUser`. External + // authorizations are revoked first, then resource data is soft-deleted. + // Both operations are idempotent so a partial failure can be retried. + // Throw from `beforeDelete` to abort: the user row and verification + // token stay intact, so the same callback can resume the attempt. + // Soft-delete handlers must be idempotent because retrying a partial + // deletion re-runs already-completed handlers as no-ops. // Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-380 - // Design: server/apps/api/docs/ai-context/account-deletion.md deleteUser: { enabled: true, async sendDeleteAccountVerification({ user, url }) { @@ -604,6 +596,7 @@ export function createAuth( }) }, async beforeDelete(user) { + await socialAuthorization.revokeForUser(user.id) await requireResourceApi(resourceApi).softDeleteUserData({ userId: user.id, reason: 'user-requested', diff --git a/server/apps/auth/src/social-authorization.ts b/server/apps/auth/src/social-authorization.ts new file mode 100644 index 000000000..55d02812b --- /dev/null +++ b/server/apps/auth/src/social-authorization.ts @@ -0,0 +1,257 @@ +import type { AuthDatabase } from './db' +import type { AuthEnv } from './env' + +import { Buffer } from 'node:buffer' + +import { eq } from 'drizzle-orm' +import { importPKCS8, SignJWT } from 'jose' +import { literal, object, safeParse } from 'valibot' + +import * as authSchema from '@proj-airi/auth-shared' + +import { createBadGatewayError, createServiceUnavailableError } from './error' + +type AppleCredentials = Pick + +type SocialAuthorizationCredentials = AppleCredentials & Pick + +interface SocialAccount { + providerId: string + accessToken: string | null + refreshToken: string | null +} + +const GoogleInvalidTokenResponseSchema = object({ + error: literal('invalid_token'), +}) + +/** Revokes external social-provider authorizations retained for a user. */ +export interface SocialAuthorizationRevoker { + /** + * Revokes every linked external authorization before the local user row is + * deleted. Credential accounts are local-only and are intentionally ignored. + */ + revokeForUser: (userId: string) => Promise +} + +/** Creates the signed client assertion required by Apple's token endpoints. */ +export async function createAppleClientSecret(credentials: AppleCredentials): Promise { + const key = await importPKCS8(credentials.AUTH_APPLE_PRIVATE_KEY_PEM, 'ES256') + const issuedAt = Math.floor(Date.now() / 1000) + + return await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: credentials.AUTH_APPLE_KEY_ID }) + .setIssuer(credentials.AUTH_APPLE_TEAM_ID) + .setSubject(credentials.AUTH_APPLE_CLIENT_ID) + .setAudience('https://appleid.apple.com') + .setIssuedAt(issuedAt) + // Apple caps client-secret JWT validity at six months. + .setExpirationTime(issuedAt + 180 * 24 * 60 * 60) + .sign(key) +} + +function revocationToken(account: SocialAccount): { token: string, tokenType: 'refresh_token' | 'access_token' } { + if (account.refreshToken) + return { token: account.refreshToken, tokenType: 'refresh_token' } + if (account.accessToken) + return { token: account.accessToken, tokenType: 'access_token' } + + throw createServiceUnavailableError( + `No ${account.providerId} token is available to revoke this authorization.`, + 'oauth/revocation_token_missing', + { providerId: account.providerId }, + ) +} + +async function revokeAppleAuthorization( + account: SocialAccount, + credentials: SocialAuthorizationCredentials, + fetchRequest: typeof fetch, +): Promise { + if (!credentials.AUTH_APPLE_CLIENT_ID + || !credentials.AUTH_APPLE_TEAM_ID + || !credentials.AUTH_APPLE_KEY_ID + || !credentials.AUTH_APPLE_PRIVATE_KEY_PEM) { + throw createServiceUnavailableError( + 'Apple authorization revocation is not configured.', + 'oauth/provider_not_configured', + { providerId: 'apple' }, + ) + } + + const { token, tokenType } = revocationToken(account) + const clientSecret = await createAppleClientSecret(credentials) + const response = await fetchRequest('https://appleid.apple.com/auth/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: credentials.AUTH_APPLE_CLIENT_ID, + client_secret: clientSecret, + token, + token_type_hint: tokenType, + }), + }) + + if (!response.ok) { + throw createBadGatewayError('Apple authorization revocation failed.', { + providerId: 'apple', + statusCode: response.status, + }) + } +} + +async function isInactiveGoogleToken(response: Response): Promise { + if (response.status !== 400) + return false + + const body = await response.json().catch(() => undefined) + return safeParse(GoogleInvalidTokenResponseSchema, body).success +} + +async function revokeGoogleToken(token: string, fetchRequest: typeof fetch): Promise<'revoked' | 'inactive'> { + const response = await fetchRequest('https://oauth2.googleapis.com/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token }), + }) + + // Google reports expired and previously revoked tokens as invalid_token. + // Either state means the saved credential can no longer authorize AIRI, so + // accepting it makes a partially completed deletion safe to retry. + if (response.ok) + return 'revoked' + if (await isInactiveGoogleToken(response)) + return 'inactive' + + throw createBadGatewayError('Google authorization revocation failed.', { + providerId: 'google', + statusCode: response.status, + }) +} + +async function revokeGoogleAuthorization(account: SocialAccount, fetchRequest: typeof fetch): Promise { + const { token, tokenType } = revocationToken(account) + const result = await revokeGoogleToken(token, fetchRequest) + + // An inactive refresh token cannot revoke a still-live access token. Try the + // separately retained access token before accepting the authorization as + // gone; Google links a successful access-token revocation back to its grant. + if (result === 'inactive' + && tokenType === 'refresh_token' + && account.accessToken + && account.accessToken !== token) { + await revokeGoogleToken(account.accessToken, fetchRequest) + } +} + +function githubHeaders(credentials: SocialAuthorizationCredentials): Record { + return { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Basic ${Buffer.from(`${credentials.AUTH_GITHUB_CLIENT_ID}:${credentials.AUTH_GITHUB_CLIENT_SECRET}`).toString('base64')}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + } +} + +async function revokeGitHubAuthorization( + account: SocialAccount, + credentials: SocialAuthorizationCredentials, + fetchRequest: typeof fetch, +): Promise { + if (!credentials.AUTH_GITHUB_CLIENT_ID || !credentials.AUTH_GITHUB_CLIENT_SECRET) { + throw createServiceUnavailableError( + 'GitHub authorization revocation is not configured.', + 'oauth/provider_not_configured', + { providerId: 'github' }, + ) + } + if (!account.accessToken) { + throw createServiceUnavailableError( + 'No GitHub access token is available to revoke this application grant.', + 'oauth/revocation_token_missing', + { providerId: 'github' }, + ) + } + + const headers = githubHeaders(credentials) + const body = JSON.stringify({ access_token: account.accessToken }) + const applicationsUrl = `https://api.github.com/applications/${encodeURIComponent(credentials.AUTH_GITHUB_CLIENT_ID)}` + const response = await fetchRequest(`${applicationsUrl}/grant`, { + method: 'DELETE', + headers, + body, + }) + + if (response.status === 204) + return + + // GitHub's delete endpoint does not document an idempotent "already gone" + // status. Check the token after any failed delete: 404 is the documented + // invalid-token response and proves that the grant can no longer be used. + const verificationResponse = await fetchRequest(`${applicationsUrl}/token`, { + method: 'POST', + headers, + body, + }) + if (verificationResponse.status === 404 && !account.refreshToken) + return + + throw createBadGatewayError('GitHub authorization revocation failed.', { + providerId: 'github', + statusCode: response.status, + verificationStatusCode: verificationResponse.status, + }) +} + +/** + * Creates the provider-aware authorization boundary used by account deletion. + * + * Every configured social provider must have an explicit revocation policy. + * Unknown providers abort deletion so a future login integration cannot + * silently regress to deleting only AIRI's local account records. + * + * @see https://developer.apple.com/documentation/signinwithapplerestapi/revoke-tokens + * @see https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke + * @see https://docs.github.com/en/rest/apps/oauth-applications#delete-an-app-authorization + */ +export function createSocialAuthorizationRevoker( + db: AuthDatabase, + credentials: SocialAuthorizationCredentials, + fetchRequest: typeof fetch = fetch, +): SocialAuthorizationRevoker { + return { + async revokeForUser(userId) { + const accounts = await db + .select({ + providerId: authSchema.account.providerId, + accessToken: authSchema.account.accessToken, + refreshToken: authSchema.account.refreshToken, + }) + .from(authSchema.account) + .where(eq(authSchema.account.userId, userId)) + + for (const account of accounts) { + if (account.providerId === 'credential') + continue + if (account.providerId === 'apple') { + await revokeAppleAuthorization(account, credentials, fetchRequest) + continue + } + if (account.providerId === 'google') { + await revokeGoogleAuthorization(account, fetchRequest) + continue + } + if (account.providerId === 'github') { + await revokeGitHubAuthorization(account, credentials, fetchRequest) + continue + } + + throw createServiceUnavailableError( + `Authorization revocation is not implemented for ${account.providerId}.`, + 'oauth/revocation_not_supported', + { providerId: account.providerId }, + ) + } + }, + } +} diff --git a/server/apps/auth/src/tests/auth.test.ts b/server/apps/auth/src/tests/auth.test.ts index 59a4940e0..bbaf8bf6e 100644 --- a/server/apps/auth/src/tests/auth.test.ts +++ b/server/apps/auth/src/tests/auth.test.ts @@ -193,6 +193,51 @@ describe('createAuth', () => { expect(await trustedOrigins(new Request('http://localhost:3000/api/auth/sign-in/social'))).toContain('https://appleid.apple.com') }) + it('revokes external authorizations before deleting resource data', async () => { + const calls: string[] = [] + const auth = createAuth( + {} as unknown as AuthDatabase, + { + PUBLIC_URL: 'http://localhost:3000', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret', + ADDITIONAL_TRUSTED_ORIGINS: [], + } as unknown as AuthEnv, + undefined, + undefined, + { + async softDeleteUserData() { + calls.push('resource-data') + }, + async trackAuthEvent() {}, + }, + { + async revokeForUser() { + calls.push('external-authorizations') + }, + }, + ) + + const beforeDelete = auth.options.user?.deleteUser?.beforeDelete + if (!beforeDelete) + throw new TypeError('Expected account-deletion hook') + + await beforeDelete({ + id: 'user-1', + name: 'User One', + email: 'user@example.com', + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, new Request('http://localhost:3000/api/auth/delete-user')) + + expect(calls).toEqual(['external-authorizations', 'resource-data']) + }) + it('uses the Caddy public API origin as the Better Auth base URL', () => { const auth = createAuth({} as unknown as AuthDatabase, { PUBLIC_URL: 'https://api.airi.build', diff --git a/server/apps/auth/src/tests/social-authorization.test.ts b/server/apps/auth/src/tests/social-authorization.test.ts new file mode 100644 index 000000000..989ba2d4e --- /dev/null +++ b/server/apps/auth/src/tests/social-authorization.test.ts @@ -0,0 +1,263 @@ +import type { AuthDatabase } from '../db' +import type { AuthEnv } from '../env' + +import { Buffer } from 'node:buffer' +import { generateKeyPairSync } from 'node:crypto' + +import { decodeJwt } from 'jose' +import { describe, expect, it, vi } from 'vitest' + +import { createSocialAuthorizationRevoker } from '../social-authorization' + +interface SocialAccount { + providerId: string + accessToken: string | null + refreshToken: string | null +} + +function createCredentials(): Pick { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }) + + return { + AUTH_GOOGLE_CLIENT_ID: 'google-client-id', + AUTH_GOOGLE_CLIENT_SECRET: 'google-client-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client-id', + AUTH_GITHUB_CLIENT_SECRET: 'github-client-secret', + AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_TEAM_ID: 'apple-team-id', + AUTH_APPLE_KEY_ID: 'apple-key-id', + AUTH_APPLE_PRIVATE_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + } +} + +function createAccountDb(accounts: SocialAccount[]): AuthDatabase { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => accounts), + })), + })), + } as unknown as AuthDatabase +} + +describe('social authorization revocation', () => { + it('revokes the saved Apple refresh token', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 200 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'apple', accessToken: 'apple-access-token', refreshToken: 'apple-refresh-token' }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + expect(fetchRequest).toHaveBeenCalledTimes(1) + const [url, init] = fetchRequest.mock.calls[0] + expect(url.toString()).toBe('https://appleid.apple.com/auth/revoke') + expect(init?.method).toBe('POST') + expect(init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }) + + const body = new URLSearchParams(init?.body?.toString()) + expect(body.get('client_id')).toBe('apple-service-id') + expect(body.get('token')).toBe('apple-refresh-token') + expect(body.get('token_type_hint')).toBe('refresh_token') + + const clientSecret = body.get('client_secret') + if (!clientSecret) + throw new TypeError('Expected Apple client secret') + const claims = decodeJwt(clientSecret) + expect(claims.iss).toBe('apple-team-id') + expect(claims.sub).toBe('apple-service-id') + expect(claims.aud).toBe('https://appleid.apple.com') + }) + + it('uses the Apple access token when no refresh token was retained', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 200 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'apple', accessToken: 'apple-access-token', refreshToken: null }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + const [, init] = fetchRequest.mock.calls[0] + const body = new URLSearchParams(init?.body?.toString()) + expect(body.get('token')).toBe('apple-access-token') + expect(body.get('token_type_hint')).toBe('access_token') + }) + + it('aborts deletion when Apple rejects revocation', async () => { + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'apple', accessToken: null, refreshToken: 'apple-refresh-token' }]), + createCredentials(), + vi.fn(async () => Response.json({ error: 'invalid_client' }, { status: 400 })), + ) + + await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({ + statusCode: 502, + errorCode: 'BAD_GATEWAY', + details: { providerId: 'apple', statusCode: 400 }, + }) + }) + + it('revokes Google with the refresh token when available', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 200 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: 'google-refresh-token' }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + expect(fetchRequest).toHaveBeenCalledTimes(1) + const [url, init] = fetchRequest.mock.calls[0] + expect(url.toString()).toBe('https://oauth2.googleapis.com/revoke') + expect(init?.method).toBe('POST') + expect(init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }) + expect(new URLSearchParams(init?.body?.toString()).get('token')).toBe('google-refresh-token') + }) + + it('treats an already invalid Google token as revoked on retry', async () => { + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: null }]), + createCredentials(), + vi.fn(async () => Response.json({ error: 'invalid_token' }, { status: 400 })), + ) + + await expect(revoker.revokeForUser('user-1')).resolves.toBeUndefined() + }) + + it('falls back to the Google access token when the refresh token is inactive', async () => { + const fetchRequest = vi.fn() + .mockResolvedValueOnce(Response.json({ error: 'invalid_token' }, { status: 400 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'google', accessToken: 'google-access-token', refreshToken: 'google-refresh-token' }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + expect(fetchRequest).toHaveBeenCalledTimes(2) + const [, accessTokenRequest] = fetchRequest.mock.calls[1] + expect(new URLSearchParams(accessTokenRequest?.body?.toString()).get('token')).toBe('google-access-token') + }) + + it('deletes the GitHub application grant with app authentication', async () => { + const fetchRequest = vi.fn(async () => new Response(null, { status: 204 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + expect(fetchRequest).toHaveBeenCalledTimes(1) + const [url, init] = fetchRequest.mock.calls[0] + expect(url.toString()).toBe('https://api.github.com/applications/github-client-id/grant') + expect(init?.method).toBe('DELETE') + expect(init?.headers).toEqual({ + 'Accept': 'application/vnd.github+json', + 'Authorization': `Basic ${Buffer.from('github-client-id:github-client-secret').toString('base64')}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }) + expect(init?.body).toBe('{"access_token":"github-access-token"}') + }) + + it('accepts a missing GitHub token after a retry verifies it is gone', async () => { + const fetchRequest = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 422 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]), + createCredentials(), + fetchRequest, + ) + + await expect(revoker.revokeForUser('user-1')).resolves.toBeUndefined() + + expect(fetchRequest).toHaveBeenCalledTimes(2) + const [url, init] = fetchRequest.mock.calls[1] + expect(url.toString()).toBe('https://api.github.com/applications/github-client-id/token') + expect(init?.method).toBe('POST') + }) + + it('aborts deletion when GitHub still reports the token as active', async () => { + const fetchRequest = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(Response.json({ id: 1 }, { status: 200 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: null }]), + createCredentials(), + fetchRequest, + ) + + await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({ + statusCode: 502, + errorCode: 'BAD_GATEWAY', + details: { providerId: 'github', statusCode: 503, verificationStatusCode: 200 }, + }) + }) + + it('does not treat an invalid GitHub access token as revoked while a refresh token remains', async () => { + const fetchRequest = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 422 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'github', accessToken: 'github-access-token', refreshToken: 'github-refresh-token' }]), + createCredentials(), + fetchRequest, + ) + + await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({ + statusCode: 502, + details: { providerId: 'github', statusCode: 422, verificationStatusCode: 404 }, + }) + }) + + it('ignores credential accounts because they have no external grant', async () => { + const fetchRequest = vi.fn() + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'credential', accessToken: null, refreshToken: null }]), + createCredentials(), + fetchRequest, + ) + + await revoker.revokeForUser('user-1') + + expect(fetchRequest).not.toHaveBeenCalled() + }) + + it('aborts deletion when a social account has no revocable token', async () => { + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'google', accessToken: null, refreshToken: null }]), + createCredentials(), + vi.fn(), + ) + + await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({ + statusCode: 503, + errorCode: 'oauth/revocation_token_missing', + details: { providerId: 'google' }, + }) + }) + + it('aborts deletion for an external provider without a revocation policy', async () => { + const revoker = createSocialAuthorizationRevoker( + createAccountDb([{ providerId: 'future-provider', accessToken: 'token', refreshToken: null }]), + createCredentials(), + vi.fn(), + ) + + await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({ + statusCode: 503, + errorCode: 'oauth/revocation_not_supported', + details: { providerId: 'future-provider' }, + }) + }) +})