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
1 change: 1 addition & 0 deletions projects/developer/src/lib/api/DeveloperAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export type DeveloperAccount = {
username: string;
expiresAt: number;
isExpired: boolean;
hasSessionError: boolean;
source: 'developer-oauth';
};
7 changes: 6 additions & 1 deletion projects/developer/src/lib/api/accountRequest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { markRejectedSession } from '$lib/auth/markRejectedSession.ts';
import { accessToken } from '$lib/auth/accessToken.ts';
import { developerErrorMessage } from './developerErrorMessage.ts';
import { traktHeaders } from './traktHeaders.ts';
Expand Down Expand Up @@ -33,12 +34,16 @@ export async function accountRequest({
cache: 'no-store',
redirect: 'error',
});
if (response.status === 401) {
await markRejectedSession({ slot, accessToken: token });
}
if (!response.ok) {
const localized = developerErrorMessage(await errorCode(response));
const messages: Record<number, string> = {
400:
'The request could not be accepted. Check your details and try again.',
401: 'Your session has expired. Refresh your account or sign in again.',
401:
'Your session is no longer valid. Sign in again from the account menu.',
403:
'This account cannot perform this action. Check your app limit and your GitHub account connection.',
404: 'This app is no longer available. Reload your apps.',
Expand Down
2 changes: 1 addition & 1 deletion projects/developer/src/lib/api/developerErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const DEVELOPER_ERROR_MESSAGES = {
authentication_required:
'Your session has expired. Refresh your account or sign in again.',
'Your session is no longer valid. Sign in again from the account menu.',
invalid_request_body:
'The request could not be accepted. Check your details and try again.',
invalid_application_id: 'This app could not be found. Reload your apps.',
Expand Down
11 changes: 11 additions & 0 deletions projects/developer/src/lib/api/executeApiRequest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ beforeEach(() => vi.mocked(accessToken).mockResolvedValue('token-value'));
afterEach(() => vi.unstubAllGlobals());

describe('execute api request', () => {
it('does not send a request when token refresh fails', async () => {
const fetch = respondWith('[]');
vi.mocked(accessToken).mockRejectedValueOnce(
new Error('Refresh unavailable'),
);

await expect(executeApiRequest({ ...BASE, accountSlot: 1 }))
.rejects.toThrow('Refresh unavailable');
expect(fetch).not.toHaveBeenCalled();
});

it('sends the public client id and api version', async () => {
const fetch = respondWith('[]');
await executeApiRequest(BASE);
Expand Down
5 changes: 5 additions & 0 deletions projects/developer/src/lib/api/executeApiRequest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PUBLIC_TRAKT_CLIENT_ID } from '$env/static/public';
import { markRejectedSession } from '$lib/auth/markRejectedSession.ts';
import { accessToken } from '$lib/auth/accessToken.ts';
import type { ApiExecutionRequest } from './ApiExecutionRequest.ts';
import type { ApiExecutionResponse } from './ApiExecutionResponse.ts';
Expand Down Expand Up @@ -104,6 +105,10 @@ export async function executeApiRequest(
throw new Error('The Trakt API request could not be completed.');
}

if (response.status === 401 && slot !== null && token) {
await markRejectedSession({ slot, accessToken: token });
}

const text = await response.text();
const size = new TextEncoder().encode(text).length;

Expand Down
43 changes: 0 additions & 43 deletions projects/developer/src/lib/api/mutateAccount.spec.ts

This file was deleted.

19 changes: 0 additions & 19 deletions projects/developer/src/lib/api/mutateAccount.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const AUTH_REQUEST_TIMEOUT_SECONDS = 15;
13 changes: 9 additions & 4 deletions projects/developer/src/lib/auth/accessToken.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { accountSessionErrors } from './accountSessionErrors.ts';
import { createAccessTokenProvider } from './createAccessTokenProvider.ts';
import { userManager } from './userManager.ts';
import { withAccountLock } from './withAccountLock.ts';

export async function accessToken(slot: number): Promise<string | null> {
const user = await userManager(slot).getUser().catch(() => null);
return user?.access_token ?? null;
}
export const accessToken = createAccessTokenProvider({
manager: userManager,
onSessionError: accountSessionErrors.mark,
lock: withAccountLock,
storage: () => globalThis.localStorage,
});
31 changes: 31 additions & 0 deletions projects/developer/src/lib/auth/accountSessionErrors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { accountSessionErrors } from './accountSessionErrors.ts';

afterEach(() => {
accountSessionErrors.clear(0);
accountSessionErrors.clear(1);
});

describe('account session errors', () => {
it('notifies once per failed account without causing retry loops', () => {
const changed = vi.fn();
const stop = accountSessionErrors.subscribe(changed);
accountSessionErrors.mark(0);
accountSessionErrors.mark(0);
expect(changed).toHaveBeenCalledTimes(1);
expect(accountSessionErrors.has(0)).toBe(true);
expect(accountSessionErrors.has(1)).toBe(false);
stop();
});

it('clears a recovered session and unsubscribes observers', () => {
const changed = vi.fn();
const stop = accountSessionErrors.subscribe(changed);
accountSessionErrors.mark(0);
accountSessionErrors.clear(0);
expect(accountSessionErrors.has(0)).toBe(false);
stop();
accountSessionErrors.mark(1);
expect(changed).toHaveBeenCalledTimes(1);
});
});
21 changes: 21 additions & 0 deletions projects/developer/src/lib/auth/accountSessionErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const errors = new Set<number>();
const listeners = new Set<() => void>();

export const accountSessionErrors = {
has: (slot: number): boolean => errors.has(slot),
mark(slot: number): void {
if (errors.has(slot)) return;

errors.add(slot);
listeners.forEach((listener) => listener());
},
clear(slot: number): void {
errors.delete(slot);
},
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
44 changes: 44 additions & 0 deletions projects/developer/src/lib/auth/authFetch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { authFetch } from './authFetch.ts';

const timedRequest = {
timeoutInSeconds: 15,
signal: new AbortController().signal,
};

describe('authentication fetch', () => {
it('buffers timed OIDC responses without replacing their metadata or consuming their body', async () => {
const response = new Response('{"ok":true}', {
headers: { 'content-type': 'application/json' },
});
const fetcher = vi.fn().mockResolvedValue(response);
const result = await authFetch(fetcher)(
'https://auth.trakt.tv/oauth/token',
timedRequest,
);
expect(result).toBe(response);
expect(result.bodyUsed).toBe(false);
expect(await result.json()).toEqual({ ok: true });
expect(fetcher).toHaveBeenCalledWith(
'https://auth.trakt.tv/oauth/token',
timedRequest,
);
});

it.each([
['https://api.trakt.tv/users/settings', {}],
['https://auth.trakt.tv/oauth/token', {}],
['https://example.com/resource', {}],
])('leaves unrelated requests unbuffered: %s', async (url, init) => {
const response = new Response('unchanged');
const clone = vi.spyOn(response, 'clone');
const fetcher = vi.fn().mockResolvedValue(response);
expect(await authFetch(fetcher)(url, init)).toBe(response);
expect(clone).not.toHaveBeenCalled();
});

it('does not wrap the same fetch more than once', () => {
const wrapped = authFetch(vi.fn());
expect(authFetch(wrapped)).toBe(wrapped);
});
});
22 changes: 22 additions & 0 deletions projects/developer/src/lib/auth/authFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const wrappedFetches = new WeakSet<typeof fetch>();

export function authFetch(fetcher: typeof fetch): typeof fetch {
if (wrappedFetches.has(fetcher)) return fetcher;

const wrapped: typeof fetch = async (input, init) => {
const response = await fetcher.call(globalThis, input, init);

// oidc-client-ts passes its timeout through to fetch, but clears the timer
// before reading JSON. Keep that timer alive until the body is buffered.
if (
init && 'timeoutInSeconds' in init &&
typeof init.timeoutInSeconds === 'number' && init.timeoutInSeconds > 0
) {
await response.clone().arrayBuffer();
}

return response;
};
wrappedFetches.add(wrapped);
return wrapped;
}
7 changes: 5 additions & 2 deletions projects/developer/src/lib/auth/completeSignIn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { rememberSlot } from './rememberSlot.ts';
import { takePendingSlot } from './takePendingSlot.ts';
import { storeUsername } from './storeUsername.ts';
import { withAccountLock } from './withAccountLock.ts';
import { userManager } from './userManager.ts';

export async function completeSignIn(): Promise<void> {
Expand All @@ -10,7 +11,9 @@ export async function completeSignIn(): Promise<void> {
throw new Error('This sign-in could not be matched to an account.');
}

const user = await userManager(slot).signinRedirectCallback();
await storeUsername(slot, user.access_token);
await withAccountLock(slot, async () => {
const user = await userManager(slot).signinRedirectCallback();
await storeUsername(slot, user.access_token);
});
rememberSlot(slot);
}
Loading
Loading