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
47 changes: 47 additions & 0 deletions src/lib/__tests__/revoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { revokeTokens } from '../oauth';

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

function capture(status = 200) {
const calls: { url: string; body: any }[] = [];
globalThis.fetch = (async (url: string, init: any) => {
calls.push({ url, body: JSON.parse(init.body) });
return new Response('{}', { status });
}) as any;
return calls;
}

const config = { clientId: 'cid_123', gatewayUrl: 'https://hypery.ai' };

describe('revokeTokens (logout)', () => {
test('sends client_id and the refresh token so the whole family is revoked', async () => {
const calls = capture();
expect(await revokeTokens({ accessToken: 'at', refreshToken: 'rt' }, config)).toBe(true);
expect(calls).toEqual([
{
url: 'https://hypery.ai/api/oauth/revoke',
body: { token: 'rt', token_type_hint: 'refresh_token', client_id: 'cid_123' },
},
]);
});

test('falls back to the access token, still with client_id', async () => {
const calls = capture();
await revokeTokens({ accessToken: 'at' }, config);
expect(calls[0].body).toEqual({ token: 'at', token_type_hint: 'access_token', client_id: 'cid_123' });
});

test('never throws and reports failure', async () => {
capture(401);
expect(await revokeTokens({ accessToken: 'at' }, config)).toBe(false);
globalThis.fetch = (async () => {
throw new Error('offline');
}) as any;
expect(await revokeTokens({ accessToken: 'at' }, config)).toBe(false);
expect(await revokeTokens({}, config)).toBe(false);
});
});
16 changes: 4 additions & 12 deletions src/lib/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getAuthorizationUrl,
exchangeCodeForToken,
refreshAccessToken,
revokeTokens,
getUserInfo,
clearOAuthTransaction,
verifyOAuthState,
Expand Down Expand Up @@ -321,18 +322,9 @@ export function HyperyProvider({
// Get the current access token before clearing
const tokens = storage.getTokens();

// Revoke the OAuth token on the server
if (tokens?.accessToken) {
try {
await fetch(`${config.gatewayUrl}/api/oauth/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: tokens.accessToken }),
});
} catch (err) {
console.error('Failed to revoke token on server:', err);
// Continue with logout even if revocation fails
}
// Revoke the OAuth token family on the server (continues even if it fails)
if (tokens) {
await revokeTokens(tokens, { clientId: config.clientId, gatewayUrl: config.gatewayUrl });
}

// Clear local storage
Expand Down
32 changes: 32 additions & 0 deletions src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,38 @@ export async function refreshAccessToken(
};
}

/**
* Revoke this session's tokens (RFC 7009) on logout. Sends the refresh token when
* there is one, which revokes the whole token family (every access token minted
* from it), else the access token. `client_id` is required by the gateway; a
* public PKCE client sends no secret. Never throws — logout continues regardless.
*/
export async function revokeTokens(
tokens: { accessToken?: string; refreshToken?: string },
config: {
clientId: string;
gatewayUrl: string;
}
): Promise<boolean> {
const token = tokens.refreshToken || tokens.accessToken;
if (!token) return false;
try {
const response = await fetch(`${config.gatewayUrl}/api/oauth/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
token_type_hint: tokens.refreshToken ? 'refresh_token' : 'access_token',
client_id: config.clientId,
}),
});
return response.ok;
} catch (err) {
console.error('Failed to revoke token on server:', err);
return false;
}
}

/**
* Fetch the signed-in user (`GET {gatewayUrl}/api/user/me`). Throws on a non-2xx response.
*/
Expand Down
Loading