Authentik OIDC login (oppgave 3.2)

Implementerer autentisering med Authentik via @auth/sveltekit:
- OIDC authorization code flow med PKCE og state-verifisering
- JWT-callback lagrer authentik_sub (SHA256-hash, ikke UUID) for
  konsistens med maskinrommets auth_identities-tabell
- Server hooks: alle ruter unntatt /signin og /auth/* krever sesjon
- Uautentiserte brukere redirectes til /signin (303)
- Innloggingsside med client-side signIn('authentik')
- Hovedside viser innlogget bruker med logg ut-knapp
- TypeScript-typer utvidet med JWT.authentik_sub

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 13:45:33 +01:00
parent 073de4b148
commit 6bc742cc8d
12 changed files with 341 additions and 121 deletions

View file

@ -36,7 +36,7 @@ callbacks: {
`profile` er kun tilgjengelig i JWT-callbacken ved innlogging (ikke ved token-refresh), derfor må `authentik_sub` lagres i tokenet. `profile` er kun tilgjengelig i JWT-callbacken ved innlogging (ikke ved token-refresh), derfor må `authentik_sub` lagres i tokenet.
**Referanse:** `web/src/lib/server/auth.ts` **Referanse:** `frontend/src/auth.ts`
## 3. Redirect-URI i Authentik ## 3. Redirect-URI i Authentik

File diff suppressed because it is too large Load diff

View file

@ -21,5 +21,9 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1"
},
"dependencies": {
"@auth/core": "^0.34.3",
"@auth/sveltekit": "^1.11.1"
} }
} }

15
frontend/src/app.d.ts vendored
View file

@ -1,13 +1,22 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // Types are augmented by @auth/sveltekit (see node_modules/@auth/sveltekit/dist/types.d.ts)
import type { Session } from '@auth/core/types';
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {} — extended by @auth/sveltekit
// interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
// Extend JWT token with authentik_sub
declare module '@auth/core/jwt' {
interface JWT {
authentik_sub?: string;
}
}
export {}; export {};

55
frontend/src/auth.ts Normal file
View file

@ -0,0 +1,55 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import type { OIDCConfig } from '@auth/core/providers';
import { env } from '$env/dynamic/private';
interface AuthentikProfile {
sub: string;
email: string;
name: string;
preferred_username: string;
groups: string[];
}
export const { handle, signIn, signOut } = SvelteKitAuth({
trustHost: true,
providers: [
{
id: 'authentik',
name: 'Authentik',
type: 'oidc',
issuer: env.AUTHENTIK_ISSUER,
clientId: env.AUTHENTIK_CLIENT_ID,
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
authorization: {
params: {
scope: 'openid email profile offline_access'
}
},
checks: ['pkce', 'state'],
profile(profile: AuthentikProfile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email
};
}
} satisfies OIDCConfig<AuthentikProfile>
],
callbacks: {
jwt({ token, profile }) {
// profile is only available on initial sign-in, not on refresh.
// Store authentik_sub in the token so it persists across refreshes.
if (profile?.sub) {
token.authentik_sub = profile.sub;
}
return token;
},
session({ session, token }) {
if (session.user) {
// Use Authentik sub as user ID (not @auth/sveltekit's internal ID)
session.user.id = (token.authentik_sub ?? token.sub) as string;
}
return session;
}
}
});

View file

@ -0,0 +1,23 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { handle as authHandle } from './auth';
import { sequence } from '@sveltejs/kit/hooks';
/** Protect all routes except /signin and /auth/* (OIDC callback paths). */
const authorizationHandle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
// Allow auth-related routes through without session check
if (path.startsWith('/auth/') || path === '/signin') {
return resolve(event);
}
const session = await event.locals.auth();
if (!session?.user) {
throw redirect(303, '/signin');
}
return resolve(event);
};
// Authentication first (sets up locals.auth), then authorization
export const handle: Handle = sequence(authHandle, authorizationHandle);

View file

@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
return {
session: await event.locals.auth()
};
};

View file

@ -1,6 +1,22 @@
<script lang="ts">
import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50"> <div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="text-center"> <div class="text-center space-y-4">
<h1 class="text-4xl font-bold text-gray-900">Synops</h1> <h1 class="text-4xl font-bold text-gray-900">Synops</h1>
<p class="mt-2 text-gray-600">Plattform for redaksjonelt arbeid</p> <p class="text-gray-600">Plattform for redaksjonelt arbeid</p>
{#if $page.data.session?.user}
<p class="text-sm text-gray-500">
Innlogget som <span class="font-medium text-gray-700">{$page.data.session.user.name ?? $page.data.session.user.email}</span>
</p>
<button
onclick={() => signOut()}
class="rounded-lg bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
>
Logg ut
</button>
{/if}
</div> </div>
</div> </div>

View file

@ -0,0 +1,4 @@
import { signIn } from '../../auth';
import type { Actions } from './$types';
export const actions: Actions = { default: signIn };

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { signIn } from '@auth/sveltekit/client';
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-sm space-y-6 text-center">
<div>
<h1 class="text-4xl font-bold text-gray-900">Synops</h1>
<p class="mt-2 text-gray-600">Logg inn for å fortsette</p>
</div>
<button
onclick={() => signIn('authentik')}
class="w-full rounded-lg bg-gray-900 px-4 py-3 text-sm font-medium text-white hover:bg-gray-800 focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 focus:outline-none"
>
Logg inn med Authentik
</button>
</div>
</div>

View file

@ -0,0 +1,4 @@
import { signOut } from '../../auth';
import type { Actions } from './$types';
export const actions: Actions = { default: signOut };

View file

@ -61,8 +61,7 @@ Uavhengige faser kan fortsatt plukkes.
## Fase 3: Frontend — skjelett ## Fase 3: Frontend — skjelett
- [x] 3.1 SvelteKit-prosjekt: opprett `frontend/` med TypeScript, TailwindCSS. PWA-manifest. Lokal dev med HMR. - [x] 3.1 SvelteKit-prosjekt: opprett `frontend/` med TypeScript, TailwindCSS. PWA-manifest. Lokal dev med HMR.
- [~] 3.2 Authentik login: OIDC-flow (authorization code + PKCE). Session-håndtering. Redirect til login ved 401. - [x] 3.2 Authentik login: OIDC-flow (authorization code + PKCE). Session-håndtering. Redirect til login ved 401.
> Påbegynt: 2026-03-17T13:40
- [ ] 3.3 STDB WebSocket-klient: abonner på noder og edges. Reaktiv Svelte-store som oppdateres ved endringer. - [ ] 3.3 STDB WebSocket-klient: abonner på noder og edges. Reaktiv Svelte-store som oppdateres ved endringer.
- [ ] 3.4 Mottaksflaten v0: vis noder med edge til innlogget bruker, sortert på `created_at`. Enkel liste med tittel og utdrag. - [ ] 3.4 Mottaksflaten v0: vis noder med edge til innlogget bruker, sortert på `created_at`. Enkel liste med tittel og utdrag.
- [ ] 3.5 TipTap-editor: enkel preset (tekst, markdown, lenker). Send `create_node`-intensjon til maskinrommet ved submit. - [ ] 3.5 TipTap-editor: enkel preset (tekst, markdown, lenker). Send `create_node`-intensjon til maskinrommet ved submit.