Skip to content
Open
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
27 changes: 22 additions & 5 deletions src/routes/(pages)/wave/(flows)/login/callback/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import performLogin from './perform-login';
import performLogin, { LoginRestartRequiredError } from './perform-login';
import { page } from '$app/state';
import { goto, invalidateAll } from '$app/navigation';
import Spinner from '$lib/components/spinner/spinner.svelte';
import Button from '$lib/components/button/button.svelte';
import { AccountSuspendedError } from '$lib/utils/wave/call';
import { getKycStatus } from '$lib/utils/wave/kyc';
import { isHttpError } from '@sveltejs/kit';

let { data } = $props();
let { backTo, skipWelcome } = $derived(data);

let error = $state<boolean>(false);
let errorMessage = $state<string | null>(null);

let loginHref = $derived(`/wave/login${backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''}`);

// If backTo already points at /wave/kyc-required (e.g. the user was bounced
// through login from that page), unwrap the inner backTo so we don't nest
Expand Down Expand Up @@ -73,8 +77,17 @@
return goto('/wave/suspended');
}

// This code was already redeemed by an earlier load of this page (e.g.
// a reload mid-exchange). If that attempt actually established a
// session, the login page bounces straight to backTo; otherwise it's a
// single tap to restart, which GitHub re-authorizes instantly.
if (err instanceof LoginRestartRequiredError) {
return goto(loginHref);
}

// eslint-disable-next-line no-console
console.error('Login callback error:', err);
console.error('Login callback error:', isHttpError(err) ? err.body : err);
errorMessage = isHttpError(err) ? err.body.message : null;
error = true;
}
});
Expand All @@ -89,11 +102,15 @@
>
{#if error}
<div class="typo-heading-3" style:text-align="center" style:color="var(--color-error)">
Something went wrong. Please try again, and if this problem persists, contact us at
support@drips.network.
{#if errorMessage}
{errorMessage} If this problem persists, contact us at support@drips.network.
{:else}
Something went wrong. Please try again, and if this problem persists, contact us at
support@drips.network.
{/if}
</div>

<Button href="/wave/login">Try again</Button>
<Button href={loginHref}>Try again</Button>
{:else}
<Spinner />
One moment please, you're being logged in...
Expand Down
82 changes: 80 additions & 2 deletions src/routes/(pages)/wave/(flows)/login/callback/perform-login.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,67 @@
import { redeemGitHubOAuthCode } from '$lib/utils/wave/auth';
import { AccountSuspendedError } from '$lib/utils/wave/call';
import { error } from '@sveltejs/kit';
import { error, isHttpError } from '@sveltejs/kit';
import { browser } from '$app/environment';

/**
* Thrown when this callback's code was already submitted by an earlier load of
* the page, so redeeming it again is pointless — the login flow needs to be
* restarted from the login page instead.
*/
export class LoginRestartRequiredError extends Error {
constructor() {
super('OAuth code was already redeemed by a previous attempt');
this.name = 'LoginRestartRequiredError';
}
}

const ATTEMPTED_CODE_KEY = 'wave-oauth-attempted-code';

/**
* sessionStorage access throws when storage is blocked entirely (e.g. Safari's
* "block all cookies", some embedded webviews). The reload guard is
* best-effort — without storage we just lose reload protection, which must
* never prevent the login attempt itself.
*/
function getAttemptedCode(): string | null {
try {
return sessionStorage.getItem(ATTEMPTED_CODE_KEY);
} catch {
return null;
}
}

function markCodeAttempted(code: string): void {
try {
sessionStorage.setItem(ATTEMPTED_CODE_KEY, code);
} catch {
// See getAttemptedCode.
}
}

/**
* The backend responds with `{"error": "..."}` bodies that `call()` embeds in
* its thrown Error message. Surface that message instead of a generic one —
* "OAuth session expired. Please try again." is a lot more actionable than
* blaming GitHub.
*/
function extractApiErrorMessage(err: unknown): string | null {
if (!(err instanceof Error)) return null;

const jsonPart = err.message.match(/\{.*\}$/s)?.[0];
if (!jsonPart) return null;

try {
const body: unknown = JSON.parse(jsonPart);
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {
return body.error;
}
} catch {
// Not a JSON body — fall through to the generic message.
}

return null;
}

export default async function performLogin(url: URL) {
// extract gh oauth code and state from url
Expand All @@ -11,6 +72,17 @@ export default async function performLogin(url: URL) {
throw error(400, 'Missing code or state in callback URL');
}

// The code (and the nonce behind it) is single-use, but a reload of the
// callback page re-runs this with the same code — guaranteed to fail with
// "already used", even though the first attempt may have succeeded
// server-side with its response lost (e.g. reload while the ~1.5s exchange
// was in flight). Mark the code as attempted *before* redeeming so any
// later load can tell, and restart the login instead of burning a request.
if (browser && getAttemptedCode() === code) {
throw new LoginRestartRequiredError();
}
if (browser) markCodeAttempted(code);

try {
// exchange for wave login
// this sets wave_refresh_token and wave_access_token cookies via the API response
Expand All @@ -23,6 +95,12 @@ export default async function performLogin(url: URL) {
return { accessToken, newUser };
} catch (e) {
if (e instanceof AccountSuspendedError) throw e;
throw error(500, 'GitHub OAuth exchange failed. GitHub may be experiencing issues.');
if (isHttpError(e)) throw e;

throw error(
500,
extractApiErrorMessage(e) ??
'GitHub OAuth exchange failed. GitHub may be experiencing issues.',
);
}
}
Loading