Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/cookie #426

Merged
merged 3 commits into from
Feb 13, 2025
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ jobs:
run: 'deno task build:preview'
env:
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_SESSION_SECRET: ${{ secrets.TRAKT_SESSION_SECRET }}

- name: Witnessing the Premonition ake Run Preview
working-directory: projects/client
Expand Down Expand Up @@ -202,6 +203,7 @@ jobs:
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
FIREBASE_MEASUREMENT_ID: ${{ secrets.FIREBASE_MEASUREMENT_ID }}
FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
TRAKT_SESSION_SECRET: ${{ secrets.TRAKT_SESSION_SECRET }}

- name: Delivering the Goods aka Upload Build
if: github.ref == 'refs/heads/main'
Expand Down
1 change: 0 additions & 1 deletion INFRASTRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ intone the following incantation:
# This is required if the secrets are not already set or have changed
echo "$TRAKT_CLIENT_ID" | npx wrangler pages secret put TRAKT_CLIENT_ID
echo "$TRAKT_CLIENT_SECRET" | npx wrangler pages secret put TRAKT_CLIENT_SECRET
echo "$TRAKT_SESSION_SECRET" | npx wrangler pages secret put TRAKT_SESSION_SECRET

# This will build the client and deploy it to Cloudflare Pages
[deno|npm|bun] task build && npx wrangler pages deploy
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ properly:
- **`TRAKT_CLIENT_ID`:** The client ID for the Trakt API.
- **`TRAKT_CLIENT_SECRET`:** The client secret for the Trakt API.
- Required for the `playground` project.
- **`TRAKT_SESSION_SECRET`:** The session encryption secret for the Trakt API.
- `deno task insecurity:generate`

### External Contribution - Unleash Your Inner Code Wizard!

Expand Down
24 changes: 0 additions & 24 deletions projects/client/.scripts/generate-insecurity.ts

This file was deleted.

3 changes: 1 addition & 2 deletions projects/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
"i18n:resolve": "deno run --allow-read --allow-write .scripts/resolve-i18n.ts",
"i18n:traktify": "deno run --allow-read --allow-write --allow-net --allow-env .scripts/traktify-i18n.ts",
"i18n:delete": "deno run --allow-read --allow-write --allow-env .scripts/delete-i18n.ts",
"cloudflare:cleanse": "deno run --allow-env --allow-net .scripts/cleanse-cloudflare.ts",
"insecurity:generate": "deno run .scripts/generate-insecurity.ts"
"cloudflare:cleanse": "deno run --allow-env --allow-net .scripts/cleanse-cloudflare.ts"
},
"devDependencies": {
"@cucumber/cucumber": "^11.2.0",
Expand Down
2 changes: 1 addition & 1 deletion projects/client/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ export const handle: Handle = sequence(
},
handleCacheControl,
handleMobileOperatingSystem,
handleGateway,
handleGateway, // TODO remove March 31st
);
33 changes: 12 additions & 21 deletions projects/client/src/lib/features/auth/handle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { AuthEndpoint } from '$lib/features/auth/AuthEndpoint.ts';
import { AuthMappedMock } from '$mocks/data/auth/AuthMappedMock.ts';
import { AuthMock } from '$mocks/data/auth/AuthMock.ts';
import { EncryptedAuthMock } from '$mocks/data/auth/EncryptedAuthMock.ts';
import { EncryptedExpiredAuthMock } from '$mocks/data/auth/EncryptedExpiredAuthMock.ts';
import { ExpiredAuthMock } from '$mocks/data/auth/ExpiredAuthMock.ts';
import { mockRequestEvent } from '$test/request/mockRequestEvent.ts';
import { describe, expect, it, vi } from 'vitest';
import { AuthEndpoint } from './AuthEndpoint.ts';
import { key } from './environment.ts';
import { AUTH_COOKIE_NAME, handle } from './handle.ts';
import { decrypt } from './utils/decrypt.ts';

describe('handle: auth', () => {
it('should handle logout', async () => {
Expand Down Expand Up @@ -41,7 +40,7 @@ describe('handle: auth', () => {
});
});

it('should handle decryption failure', async () => {
it('should handle invalid cookie contents', async () => {
const event = mockRequestEvent({
url: 'http://localhost',
cookieHandler: (key: string) => {
Expand All @@ -53,27 +52,19 @@ describe('handle: auth', () => {
},
});

const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await handle({ event, resolve: vi.fn() });

expect(event.locals.auth).toEqual(null);
expect(event.cookies.getAll().find((c) => c.name)?.value)
.toEqual('');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});

it('should handle legacy auth cookie', async () => {
const decryptedAuth = await decrypt(
key,
EncryptedAuthMock,
);

it('should handle auth cookie', async () => {
const event = mockRequestEvent({
url: 'http://localhost',
cookieHandler: (key: string) => {
if (key === AUTH_COOKIE_NAME) {
return JSON.stringify(decryptedAuth);
return AuthMock;
}

return null;
Expand All @@ -82,13 +73,10 @@ describe('handle: auth', () => {

await handle({ event, resolve: vi.fn() });

expect(event.locals.auth).toEqual(decryptedAuth);
expect(event.cookies.set).toHaveBeenCalled();
expect(event.cookies.getAll().find((c) => c.name)?.value)
.toEqual(EncryptedAuthMock);
expect(event.locals.auth).toEqual(AuthMappedMock);
});

it('should handle encrypted auth cookie', async () => {
it('should handle legacy encrypted auth cookie', async () => {
const event = mockRequestEvent({
url: 'http://localhost',
cookieHandler: (key: string) => {
Expand All @@ -102,6 +90,9 @@ describe('handle: auth', () => {

await handle({ event, resolve: vi.fn() });

expect(event.cookies.set).toHaveBeenCalled();
expect(event.cookies.getAll().find((c) => c.name)?.value)
.toEqual(AuthMock);
expect(event.locals.auth).toEqual(AuthMappedMock);
});

Expand All @@ -110,7 +101,7 @@ describe('handle: auth', () => {
url: 'http://localhost',
cookieHandler: (key: string) => {
if (key === AUTH_COOKIE_NAME) {
return EncryptedExpiredAuthMock;
return ExpiredAuthMock;
}

return null;
Expand Down
43 changes: 24 additions & 19 deletions projects/client/src/lib/features/auth/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import type {
} from './models/SerializedAuthResponse.ts';
import { authorize } from './requests/authorize.ts';
import { decrypt } from './utils/decrypt.ts';
import { encrypt } from './utils/encrypt.ts';

export const AUTH_COOKIE_NAME = 'trakt-auth';
const REFRESH_THRESHOLD_MINUTES = 15;

function getLegacyAuthCookie(event: RequestEvent) {
async function getEncryptedAuth(
event: RequestEvent,
): Promise<SerializedAuthResponse | Nil> {
const encrypted = event.cookies.get(AUTH_COOKIE_NAME);
return await decrypt<SerializedAuthResponse>(key, encrypted);
}

function getAuth(event: RequestEvent): SerializedAuthResponse | null {
try {
const serializedToken = event.cookies.get(AUTH_COOKIE_NAME) ?? '';
return JSON.parse(serializedToken) as SerializedAuthResponse;
Expand Down Expand Up @@ -71,7 +77,7 @@ export const handle: Handle = async ({ event, resolve }) => {
if (isAuthorized) {
const cookie = event.cookies.serialize(
AUTH_COOKIE_NAME,
await encrypt(key, result),
JSON.stringify(result),
{
httpOnly: true,
secure: true,
Expand All @@ -92,48 +98,47 @@ export const handle: Handle = async ({ event, resolve }) => {
});
}

//TODO: remove this migration after March 1st 2025
const legacyAuthCookie = getLegacyAuthCookie(event);
if (legacyAuthCookie != null) {
setAuth(legacyAuthCookie);
const authResponse = getAuth(event);

// TODO remove this March 31
const decryptedAuthResponse = await getEncryptedAuth(event);
const isDecryptionFailed = decryptedAuthResponse == null;
if (!authResponse && !isDecryptionFailed) {
setAuth(decryptedAuthResponse);

event.cookies.set(
AUTH_COOKIE_NAME,
await encrypt(key, legacyAuthCookie),
JSON.stringify(decryptedAuthResponse),
{
httpOnly: true,
secure: true,
expires: new Date(legacyAuthCookie.expiresAt ?? 0),
expires: new Date(decryptedAuthResponse?.expiresAt ?? 0),
path: '/',
},
);

return await resolve(event);
}

const encrypted = event.cookies.get(AUTH_COOKIE_NAME);
const decrypted = await decrypt<SerializedAuthResponse>(key, encrypted);
const isDecryptionFailed = decrypted == null && encrypted != null;

const minutesToExpiry = Math.floor(
(new Date(decrypted?.expiresAt ?? 0).getTime() - Date.now()) /
(new Date(authResponse?.expiresAt ?? 0).getTime() - Date.now()) /
time.minutes(1),
);
const shouldRefresh = minutesToExpiry <= REFRESH_THRESHOLD_MINUTES;

if (shouldRefresh && decrypted?.token.refresh != null) {
if (shouldRefresh && authResponse?.token.refresh != null) {
const result = await authorize({
token: {
type: 'refresh',
refreshToken: decrypted.token.refresh,
refreshToken: authResponse.token.refresh,
},
referrer: getReferrer(),
});

setAuth(result);
event.cookies.set(
AUTH_COOKIE_NAME,
await encrypt(key, result),
JSON.stringify(result),
{
httpOnly: true,
secure: true,
Expand All @@ -145,9 +150,9 @@ export const handle: Handle = async ({ event, resolve }) => {
return await resolve(event);
}

setAuth(decrypted);
setAuth(authResponse);

if (isDecryptionFailed) {
if (!authResponse) {
event.cookies.set(AUTH_COOKIE_NAME, '', {
httpOnly: true,
secure: true,
Expand Down
2 changes: 2 additions & 0 deletions projects/client/src/lib/features/auth/stores/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuthEndpoint } from '$lib/features/auth/AuthEndpoint.ts';
import { setToken } from '$lib/features/auth/token/index.ts';
import { InvalidateAction } from '$lib/requests/models/InvalidateAction.ts';
import { useInvalidator } from '$lib/stores/useInvalidator.ts';
import { assertDefined } from '$lib/utils/assert/assertDefined.ts';
Expand Down Expand Up @@ -39,6 +40,7 @@ export function useAuth() {
await fetch(AuthEndpoint.Logout, {
method: 'POST',
});
setToken(null);
isAuthorized.set(false);
await invalidate(InvalidateAction.Auth);
globalThis.location.href = setCacheBuster(
Expand Down
13 changes: 13 additions & 0 deletions projects/client/src/lib/features/auth/token/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const token: {
value: string | Nil;
} = {
value: null,
};

export function getToken() {
return token.value;
}

export function setToken(value: string | Nil) {
token.value = value;
}
8 changes: 1 addition & 7 deletions projects/client/src/lib/features/auth/utils/decrypt.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AuthMappedMock } from '$mocks/data/auth/AuthMappedMock.ts';
import { EncryptedAuthMock } from '$mocks/data/auth/EncryptedAuthMock.ts';
import { encryptionKeyMock } from '$mocks/data/auth/encryptionKeyMock.ts';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { decrypt } from './decrypt.ts';

describe('utils: decrypt', () => {
Expand All @@ -20,23 +20,17 @@ describe('utils: decrypt', () => {
});

it('should return null if data is invalid', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const encryptionKey = await encryptionKeyMock();
const data = await decrypt(encryptionKey, 'invalid data');

expect(data).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});

it('should return null if crypto.subtle.decrypt fails', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const encryptionKey = await encryptionKeyMock(1337);

const data = await decrypt(encryptionKey, EncryptedAuthMock);

expect(data).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
3 changes: 0 additions & 3 deletions projects/client/src/lib/features/auth/utils/decrypt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { error } from '$lib/utils/console/print.ts';

export async function decrypt<T>(
key: CryptoKey,
data: string | Nil,
Expand Down Expand Up @@ -28,7 +26,6 @@ export async function decrypt<T>(

return JSON.parse(jsonString);
} catch (_) {
error('Failed to decrypt data', _);
return null;
}
}
33 changes: 0 additions & 33 deletions projects/client/src/lib/features/auth/utils/encrypt.spec.ts

This file was deleted.

16 changes: 0 additions & 16 deletions projects/client/src/lib/features/auth/utils/encrypt.ts

This file was deleted.

Loading
Loading