mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(auth-server): revoke social authorizations on account deletion (#2221)
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user