diff options
feat: oidc attempt 82845345
| -rw-r--r-- | src/app.d.ts | 14 | ||||
| -rw-r--r-- | src/hooks.server.ts | 118 | ||||
| -rw-r--r-- | src/lib/auth.server.ts | 36 | ||||
| -rw-r--r-- | src/lib/auth.ts | 37 | ||||
| -rw-r--r-- | src/lib/oncePromise.ts | 16 | ||||
| -rw-r--r-- | src/lib/util-types.ts | 4 | ||||
| -rw-r--r-- | src/routes/+error.svelte | 1 | ||||
| -rw-r--r-- | src/routes/+layout.server.ts | 4 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 12 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 26 | ||||
| -rw-r--r-- | src/routes/+server.ts | 3 | ||||
| -rw-r--r-- | src/routes/api/v1/whoami/+server.ts | 9 | ||||
| -rw-r--r-- | src/routes/home/+page.svelte | 34 | ||||
| -rw-r--r-- | src/routes/login/+server.ts | 16 | ||||
| -rw-r--r-- | src/routes/login/callback/+server.ts | 79 | ||||
| -rw-r--r-- | src/routes/login/callback/ok/+page.svelte | 13 | ||||
| -rw-r--r-- | src/routes/login/undo/+server.ts | 16 | ||||
| -rw-r--r-- | src/routes/logout/+server.ts | 14 | ||||
| -rw-r--r-- | src/routes/vms/+page.svelte | 16 |
19 files changed, 356 insertions, 112 deletions
diff --git a/src/app.d.ts b/src/app.d.ts index cfeb8a5..4f96a65 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,11 +1,21 @@ // See https://svelte.dev/docs/kit/types#app.d.ts + +import type { authHandler } from './hooks.server'; + // for information about these interfaces -type Session = unknown; declare global { namespace App { // interface Error {} interface Locals { - auth: () => Promise<Session | null>; + /** + * Undefined: not authorized + * Null: failed to validate/renew token + */ + auth: () => Promise<Session | undefined | null>; + /** + * Removes Authentication Cookies + */ + logout: () => void; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2fe3744..05b8b18 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,32 +1,104 @@ +import oncePromise from '$lib/oncePromise'; +import type { Defined, Unpromise } from '$lib/util-types'; +import type { RequestEvent, Transport } from '@sveltejs/kit'; import * as auth from './lib/auth.server'; import * as client from 'openid-client'; // https://svelte.dev/docs/kit/hooks#Server-hooks-handle -export const handle = ({ event, resolve }) => { - event.locals.auth = async () => { - const accessToken = event.cookies.get('oid__access_token'); - const sub = event.cookies.get('oid__sub'); - console.warn({ accessToken, sub }); - if (accessToken && sub) { +export const authHandler = ( + event: RequestEvent<Partial<Record<string, string>>, string | null> +) => + oncePromise(async () => { + let refreshToken = event.cookies.get('oid__refresh_token'); + let accessToken = event.cookies.get('oid__access_token'); + let expiry = Number(event.cookies.get('oid__expires_at')); + if ( + refreshToken && + (!accessToken || isNaN(expiry) || expiry - 60 * 1000 >= Date.now()) + ) { try { - const userInfo = await client - .fetchUserInfo(await auth.getConfig(), accessToken, sub) - .catch((e) => { - console.warn(e); - - return null; - }); - console.warn({ - userInfo, - accessToken, - sub, - }); - } catch (error) {} - } else if (accessToken || sub) { - event.cookies.delete('access-token', { path: '/' }); - event.cookies.delete('sub', { path: '/' }); + const tokens = await client.refreshTokenGrant( + await auth.getConfig(), + refreshToken + ); + auth.setCookies(event.cookies, tokens); + accessToken = tokens.access_token; + refreshToken = tokens.refresh_token ?? refreshToken; + expiry = tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : expiry; + } catch (error) { + return null; + } } - return null; + if (accessToken) { + try { + const introspectionResponse = await client.tokenIntrospection( + await auth.getConfig(), + accessToken + ); + if (!introspectionResponse.active) { + auth.unsetCookies(event.cookies); + return null; + } + return { + tokens: { + scope: introspectionResponse.scope, + token_type: introspectionResponse.token_type as Lowercase<string>, + expires_at: expiry, + }, + userInfo: await client + .fetchUserInfo( + await auth.getConfig(), + accessToken, + introspectionResponse.sub ?? '' + ) + .catch((e) => { + auth.unsetCookies(event.cookies); + throw e; + }), + __is_session: 1, + }; + } catch (error) { + return null; + } + } else auth.unsetCookies(event.cookies); + return undefined; + }); +export type Session = Defined< + Unpromise<ReturnType<ReturnType<typeof authHandler>>> +>; +export type ClientSession = Omit<Session, 'tokens'> & { + tokens: { + scope?: string; + token_type?: Lowercase<string>; + expires_at?: number; + }; +}; +() => { + // just a type sanity check to ensure ClientSession is always a subset of Session + let session!: Session; + let clientSession: ClientSession = session satisfies ClientSession; + void clientSession; +}; + +export const filterSession = <T extends Session | undefined | null>( + value: T +): T extends Session ? ClientSession : T => { + type RT = T extends Session ? ClientSession : T; + if (value === null || value === undefined) return value as RT; + // clients probably shouldnt get tokens in js land (we trust the client with the token, but only over HTTP; we want to maximize the annoyance of CSRF successes) + const v = structuredClone(value) as ClientSession; + v.tokens = { + expires_at: value.tokens.expires_at, + scope: value.tokens.scope, + token_type: value.tokens.token_type, }; + return v as RT; +}; + +export const handle = ({ event, resolve }) => { + event.locals.auth = authHandler(event); + event.locals.logout = () => auth.unsetCookies(event.cookies); return resolve(event); }; diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index 77e0dd7..f762cef 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -2,6 +2,7 @@ import { env as env_priv } from '$env/dynamic/private'; import { env } from '$env/dynamic/public'; import * as client from 'openid-client'; import oncePromise from './oncePromise'; +import type { Cookies } from '@sveltejs/kit'; const server = new URL(env.PUBLIC_AUTH_KEYCLOAK_ISSUER); const clientId = env_priv.PRIVATE_AUTH_KEYCLOAK_ID; @@ -10,7 +11,10 @@ const redirectPath = '/login/callback'; // Only trigger discovery on first client.discovery (resetting the function after a failed discovery) export const getConfig = oncePromise(() => - client.discovery(server, clientId, clientSecret) + client.discovery(server, clientId, clientSecret).then((config) => { + client.useJwtResponseMode(config); + return config; + }) ); const codeVerifier = client.randomPKCECodeVerifier(); @@ -71,3 +75,33 @@ export const authorizeNewSession = async ( return tokens; }; + +export const unsetCookies = (cookies: Cookies) => { + for (const v of [ + 'oid__access_token', + 'oid__refresh_token', + 'oid__token_type', + 'oid__expires_at', + 'oid__scopes', + ]) + if (cookies.get(v)) cookies.delete(v, { path: '/' }); +}; +export const setCookies = ( + cookies: Cookies, + tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers +) => { + for (const [k, v] of Object.entries({ + oid__access_token: tokens.access_token, + oid__refresh_token: tokens.refresh_token, + oid__token_type: tokens.token_type, + oid__expires_at: '' + (Date.now() + (tokens.expiresIn() ?? 0) * 1000), + oid__scopes: tokens.scope, + })) + if (v) + cookies.set(k, v, { + path: '/', + secure: true, + httpOnly: true, + sameSite: true, + }); +}; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..dd6b043 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,37 @@ +import { browser } from '$app/environment'; +import { base } from '$app/paths'; +import { redirect } from '@sveltejs/kit'; +import type { ClientSession } from '../hooks.server'; +import { goto } from '$app/navigation'; + +/** + * Returns `true` if scopes are all included in session, otherwise either attempts to re-login with the new scope added (unless `getScopeOnFail` is false) and returns false + * + * Check the return value of this, even if getScopeOnFail is true; navigating client-side may not stop thread immediately! + */ +export const checkScope = ( + session: ClientSession, + /** The scopes we want */ + neededScopes: string[], + /** Redirect to login page if the scopes aren't found */ + getScopeOnFail = false, + /** The target URL if redirecting */ + next?: string +) => { + const scopes = session.tokens.scope?.split(' ') ?? []; + if (!neededScopes.find((v) => !scopes.includes(v))) return true; + else if (getScopeOnFail) { + const targetUrl = `${base}/login?${ + next || browser + ? `next=${next ?? encodeURIComponent(location.href)}&` + : '' + }scope=${encodeURIComponent( + [...scopes, ...neededScopes] + .filter((v, i, a) => a.indexOf(v) === i) + .join(' ') + )}`; + if (browser) goto(targetUrl); + else throw redirect(307, targetUrl); + } + return false; +}; diff --git a/src/lib/oncePromise.ts b/src/lib/oncePromise.ts index f6ce775..6ce6287 100644 --- a/src/lib/oncePromise.ts +++ b/src/lib/oncePromise.ts @@ -10,13 +10,19 @@ const ensurePromise = <T>(maybePromise: T | PromiseLike<T>): Promise<T> => ? (maybePromise as Promise<T>) : Promise.resolve(maybePromise); /** Returns a function that caches successful promises until time runs out, and throws away unsuccessful ones */ -export const oncePromise = <T>(create: () => Promise<T>, timeout = -1) => { +export const oncePromise = <T>( + create: () => Promise<T>, + retries = true, + timeout = -1 +) => { let getPromise = (): Promise<T> => { const oldGetPromise = getPromise, - promise = ensurePromise(create()).catch((e) => { - getPromise = oldGetPromise; - throw e; - }), + promise = retries + ? ensurePromise(create()).catch((e) => { + getPromise = oldGetPromise; + throw e; + }) + : ensurePromise(create()), expires = timeout > 0 ? performance.now() + timeout : 0; return (getPromise = expires ? ((() => diff --git a/src/lib/util-types.ts b/src/lib/util-types.ts new file mode 100644 index 0000000..861edcf --- /dev/null +++ b/src/lib/util-types.ts @@ -0,0 +1,4 @@ +export type Unpromise<T extends Promise<any>> = T extends Promise<infer U> + ? U + : never; +export type Defined<T> = T extends undefined | null ? never : T; diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index a19e467..c7cd13f 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -4,3 +4,4 @@ <h2 class="text-2xl">HTTP {page.status}</h2> <p>{page.error?.message}</p> +<p><a href="/home">Go Home</a></p> diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index afdac71..c6f56da 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,7 @@ +import { filterSession } from '../hooks.server'; + export const load = async ({ locals }) => { return { - // session: await locals.auth(), + session: filterSession(await locals.auth()), }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1980c3a..dc81706 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -13,8 +13,16 @@ <nav class="header"> <h1 class="text-4xl">crunched</h1> <p> - <a href="/">home</a> - {#if page.data.session}<a href="/vms">vms</a - >{:else}<a href="/login?scope=profile%20vm-own-read">login</a>{/if} + <a href="/">home</a> - {#if page.data.session}<a href="/vms">vms</a> - + <a + href="/login/undo?next={encodeURIComponent( + page.url.pathname + page.url.search + )}">logout</a + >{:else}<a + href="/login?scope=default&next={encodeURIComponent( + page.url.pathname + page.url.search + )}">login</a + >{/if} </p> <div class="my-2"> <hr /> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 2634622..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,26 +0,0 @@ -<script> - import { page } from '$app/state'; - let s = $derived(page.data.session); -</script> - -{@debug s} - -<h1>SvelteKit Auth Example</h1> -<div> - {#if page.data.session} - {#if page.data.session.user?.image} - <img - src={page.data.session.user.image} - class="avatar" - alt="User Avatar" - /> - {/if} - <span> - <small>Signed in as</small><br /> - <strong>{page.data.session.user?.name ?? 'User'}</strong> - </span> - <!-- <div slot="submitButton" class="buttonPrimary">Sign out</div> --> - {:else} - <span class="notSignedInText">You are not signed in</span> - {/if} -</div> diff --git a/src/routes/+server.ts b/src/routes/+server.ts new file mode 100644 index 0000000..c1ee9f0 --- /dev/null +++ b/src/routes/+server.ts @@ -0,0 +1,3 @@ +import { redirect } from '@sveltejs/kit'; + +export const GET = () => redirect(308, '/home'); diff --git a/src/routes/api/v1/whoami/+server.ts b/src/routes/api/v1/whoami/+server.ts new file mode 100644 index 0000000..98809a4 --- /dev/null +++ b/src/routes/api/v1/whoami/+server.ts @@ -0,0 +1,9 @@ +import { error, json } from '@sveltejs/kit'; +import { filterSession, type Session } from '../../../../hooks.server.js'; + +export const GET = async ({ locals }) => { + const data = (await locals.auth()) as Session; + if (data === undefined) throw error(403, 'Unauthorized'); + if (data === null) throw error(401, 'Session Expired'); + return json(filterSession(data)); +}; diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte new file mode 100644 index 0000000..8fa9ddd --- /dev/null +++ b/src/routes/home/+page.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { page } from '$app/state'; + import { checkScope } from '$lib/auth'; + import type { Session } from '../../hooks.server'; + let session = $derived(page.data.session as Session); +</script> + +<svelte:head> + <title>Crunched - Home</title> +</svelte:head> + +<article> + <h2 class="text-2xl">Home</h2> + <div> + {#if session} + <div> + <small>Signed in as</small><br /> + <strong>{session.userInfo.preferred_username ?? 'User'}</strong> + </div> + <div> + <small>Scope</small><br /> + <strong>{session.tokens.scope}</strong> + </div> + <button + onclick={() => { + alert(checkScope(session, ['vm-own-write'], true)); + }}>need scope</button + > + <!-- <div slot="submitButton" class="buttonPrimary">Sign out</div> --> + {:else} + <span class="notSignedInText">You are not signed in</span> + {/if} + </div> +</article> diff --git a/src/routes/login/+server.ts b/src/routes/login/+server.ts index 4a032d4..7313f13 100644 --- a/src/routes/login/+server.ts +++ b/src/routes/login/+server.ts @@ -1,10 +1,13 @@ import { getAuthorizeUrl } from '$lib/auth.server.js'; -import { error, redirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; export const GET = async (event) => { let target = event.url.searchParams.get('next') ?? '/'; - let desiredScopes = - event.url.searchParams.get('scope') ?? 'profile vm-own-read'; + let desiredScopes = event.url.searchParams.get('scope') ?? 'default'; + desiredScopes = desiredScopes + .split(' ') + .flatMap((v) => (v === 'default' ? 'vm-own-read vm-own-write' : '')) + .join(' '); if (new URL(target, event.url.href).host !== event.url.host) target = '/'; const existingScopes = (event.cookies.get('oid__scopes') ?? '').split(' '); const authed = await event.locals.auth(); @@ -44,11 +47,14 @@ export const GET = async (event) => { event.cookies.delete('pending-auth-nonces', { path: '/', }); - event.cookies.delete('next', { - path: target, + event.cookies.set('next', target, { + path: '/', }); throw redirect(303, redirectTo); } else { + event.cookies.delete('next', { + path: '/', + }); throw redirect(303, target); } }; diff --git a/src/routes/login/callback/+server.ts b/src/routes/login/callback/+server.ts index 32b1647..1de7811 100644 --- a/src/routes/login/callback/+server.ts +++ b/src/routes/login/callback/+server.ts @@ -1,5 +1,6 @@ +import { base } from '$app/paths'; import * as auth from '$lib/auth.server.js'; -import { error, json, redirect } from '@sveltejs/kit'; +import { error, isHttpError, isRedirect, json, redirect } from '@sveltejs/kit'; import * as client from 'openid-client'; // Pre-checker for nonce, not the primary implementation @@ -18,63 +19,47 @@ const handleNonce = (nonce: string | null, nonceCookie: string | undefined) => { }; export const GET = async (event) => { const sp = event.url.searchParams; - const params = { - sessionState: sp.get('session_state'), - iss: sp.get('iss'), - code: sp.get('code'), - nonce: sp.get('nonce'), - }; - if (!params.sessionState || !params.iss || !params.code) - throw error(400, 'Missing one of session_state, iss, code'); const remainingNonces = handleNonce( - params.nonce, + sp.get('nonce'), event.cookies.get('pending-auth-nonces') ); try { - const tk = await auth.authorizeNewSession( + const tokens = await auth.authorizeNewSession( new URL(event.url.href), - params.nonce ?? undefined + sp.get('nonce') ?? undefined ); - for (const [k, v] of Object.entries({ - oid__access_token: tk.access_token, - oid__token_type: tk.token_type, - oid__expires_at: '' + (Date.now() + (tk.expiresIn() ?? 0) * 1000), - oid__refresh_token: tk.refresh_token, - oid__sub: tk.claims()!.sub, - 'pending-auth-nonces': JSON.stringify(remainingNonces), - })) - if (v) - event.cookies.set(k, v, { - path: '/', - secure: true, - httpOnly: true, - sameSite: true, - }); - if (tk.scope) - event.cookies.set('oid__scopes', tk.scope, { - path: '/', - secure: true, - httpOnly: true, - sameSite: true, - }); - - console.warn( - 'New Session:', - await client.fetchUserInfo( - await auth.getConfig(), - tk.access_token, - tk.claims()!.sub - ) - ); + auth.setCookies(event.cookies, tokens); + event.cookies.set('pending-auth-nonces', JSON.stringify(remainingNonces), { + path: '/', + secure: true, + sameSite: true, + httpOnly: true, + }); - return json({ - sub: tk.claims()!.sub, - at: tk.access_token, + let target = event.cookies.get('next') ?? '/'; + if (new URL(target, event.url.href).host !== event.url.host) target = '/'; + event.cookies.delete('next', { + path: '/', }); + throw redirect( + 303, + `${base}/login/callback/ok?next=${encodeURIComponent(target)}` + ); } catch (e) { - throw redirect(307, '/login'); + if (isRedirect(e) || isHttpError(e)) throw e; + // @ts-ignore + if (e?.cause?.error === 'invalid_grant') + throw error( + 403, + 'Invalid Grant Provided - Does your account have access to all requested resources?' + ); + else + throw error( + 500, + `Could not authorize new session: ${JSON.stringify(e, null, 2)}` + ); } }; diff --git a/src/routes/login/callback/ok/+page.svelte b/src/routes/login/callback/ok/+page.svelte new file mode 100644 index 0000000..a71d962 --- /dev/null +++ b/src/routes/login/callback/ok/+page.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { page } from '$app/state'; + + let target = page.url.searchParams.get('next') ?? '/'; + if (new URL(target, page.url.href).host !== page.url.host) target = '/'; +</script> + +<svelte:head> + <meta http-equiv="refresh" content="0.1; url={target}" /> +</svelte:head> + +<h2 class="text-xl">Redirecting...</h2> +<p>If nothing happens, click <a href={target}>here</a>.</p> diff --git a/src/routes/login/undo/+server.ts b/src/routes/login/undo/+server.ts new file mode 100644 index 0000000..a3559d6 --- /dev/null +++ b/src/routes/login/undo/+server.ts @@ -0,0 +1,16 @@ +import * as auth from '$lib/auth.server.js'; +import { error, redirect } from '@sveltejs/kit'; +import * as client from 'openid-client'; +export const GET = async (event) => { + const token = event.cookies.get('oid__access_token'); + if (!token) throw error(403, 'Logout requires an access token!'); + await client.tokenRevocation(await auth.getConfig(), token); + let target = + event.url.searchParams.get('next') ?? event.cookies.get('next') ?? '/'; + if (new URL(target, event.url.href).host !== event.url.host) target = '/'; + event.cookies.delete('next', { + path: '/', + }); + auth.unsetCookies(event.cookies); + throw redirect(303, `/login/callback/ok?next=${encodeURIComponent(target)}`); +}; diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..4880c2a --- /dev/null +++ b/src/routes/logout/+server.ts @@ -0,0 +1,14 @@ +import { base } from '$app/paths'; +import { redirect } from '@sveltejs/kit'; + +export const GET = (event) => + redirect( + 307, + `${base}/login/undo${ + event.url.searchParams.get('next') + ? `?next=${encodeURIComponent( + event.url.searchParams.get('next') ?? '/' + )}` + : '' + }` + ); diff --git a/src/routes/vms/+page.svelte b/src/routes/vms/+page.svelte new file mode 100644 index 0000000..928b4f4 --- /dev/null +++ b/src/routes/vms/+page.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import { page } from '$app/state'; + import { onDestroy, onMount } from 'svelte'; + + let mounted = $state(false); + onMount(() => (mounted = true)); + onDestroy(() => (mounted = false)); +</script> + +<svelte:head> + {#if !mounted} + <noscript> + <meta http-equiv="refresh" content="5; url={page.url.href}" /> + </noscript> + {/if} +</svelte:head> |