fix(auth-server): revoke social authorizations on account deletion (#2221)

This commit is contained in:
RainbowBird
2026-08-12 23:33:58 +08:00
parent 22b5249c64
commit 818cd0803f
4 changed files with 577 additions and 19 deletions
+12 -19
View File
@@ -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',
@@ -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<AuthEnv, 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'>
type SocialAuthorizationCredentials = AppleCredentials & Pick<AuthEnv, 'AUTH_GITHUB_CLIENT_ID' | 'AUTH_GITHUB_CLIENT_SECRET'>
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<void>
}
/** Creates the signed client assertion required by Apple's token endpoints. */
export async function createAppleClientSecret(credentials: AppleCredentials): Promise<string> {
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<void> {
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<boolean> {
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<void> {
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<string, string> {
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<void> {
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 },
)
}
},
}
}
+45
View File
@@ -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',
@@ -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<AuthEnv, 'AUTH_GOOGLE_CLIENT_ID' | 'AUTH_GOOGLE_CLIENT_SECRET' | 'AUTH_GITHUB_CLIENT_ID' | 'AUTH_GITHUB_CLIENT_SECRET' | 'AUTH_APPLE_CLIENT_ID' | 'AUTH_APPLE_TEAM_ID' | 'AUTH_APPLE_KEY_ID' | 'AUTH_APPLE_PRIVATE_KEY_PEM'> {
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>()
.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<typeof fetch>(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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>()
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<typeof fetch>(),
)
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<typeof fetch>(),
)
await expect(revoker.revokeForUser('user-1')).rejects.toMatchObject({
statusCode: 503,
errorCode: 'oauth/revocation_not_supported',
details: { providerId: 'future-provider' },
})
})
})