コンテンツにスキップ

Smart Handoff SSO

このコンテンツはまだ日本語訳がありません。

Overview

Smart Handoff SSO enables secure cross-domain Single Sign-On by transferring authentication state from an Authorization Server (AS) to a Relying Party (RP) through a secure handoff token.

In SvelteKit, handoff flows are handled entirely server-side using hooks, providing:

  • Zero client-side JavaScript for authentication
  • Automatic session management via cookies
  • 303 See Other redirects (POST → GET)
  • Type-safe server-side APIs

How It Works

sequenceDiagram
    participant User
    participant AS as Authorization Server
    participant Hook as SvelteKit Hook
    participant App as SvelteKit App

    User->>AS: Already authenticated
    User->>AS: Click "Open App" button
    AS->>AS: Generate handoff_token
    AS->>Hook: Redirect with handoff_token + state
    Hook->>AS: POST /auth/external/handoff/verify
    AS->>Hook: Return access_token + session + user
    Hook->>Hook: Set session cookie
    Hook->>Hook: Clean URL (remove token)
    Hook->>App: Redirect (303 See Other)
    App->>User: Show authenticated app

Key Features:

  • Server-side only: No token exposure to client
  • Cookie-based sessions: HttpOnly, Secure, SameSite
  • Automatic cleanup: Token removed from URL
  • 303 redirects: Proper POST → GET redirect handling

Setup

1. Install Package

Terminal window
npm install @authrim/sveltekit

2. Configure Hooks

In src/hooks.server.ts:

import { sequence } from '@sveltejs/kit/hooks';
import {
createAuthHandle,
createHandoffHandler
} from '@authrim/sveltekit/server';
export const handle = sequence(
// First: Initialize auth context
createAuthHandle({
issuer: import.meta.env.VITE_AUTHRIM_ISSUER,
clientId: import.meta.env.VITE_AUTHRIM_CLIENT_ID,
callbackPaths: ['/callback'],
}),
// Second: Handle handoff callbacks
createHandoffHandler({
issuer: import.meta.env.VITE_AUTHRIM_ISSUER,
clientId: import.meta.env.VITE_AUTHRIM_CLIENT_ID,
errorRedirect: '/login?error=handoff_failed',
})
);

3. That’s It!

The handoff handler automatically:

  1. Detects handoff_token in URL parameters
  2. Verifies token with Authorization Server
  3. Saves session to cookie
  4. Removes token from URL
  5. Redirects to clean URL with 303 See Other

What createHandoffHandler() Does

The handler provides automatic processing:

// Pseudo-code of what happens internally
export function createHandoffHandler(options) {
return async ({ event, resolve }) => {
// Check for handoff token
if (event.url.searchParams.has('handoff_token')) {
const handoffToken = event.url.searchParams.get('handoff_token');
const state = event.url.searchParams.get('state');
// Verify with Authorization Server
const result = await verifyHandoffToken(event, options);
if (!result.success) {
// Error: redirect to login
throw redirect(303, options.errorRedirect || '/login');
}
// Success: remove token from URL and redirect
const cleanUrl = new URL(event.url);
cleanUrl.searchParams.delete('handoff_token');
cleanUrl.searchParams.delete('state');
throw redirect(303, cleanUrl.toString());
}
return resolve(event);
};
}

Configuration Options

interface HandoffVerifyOptions {
/**
* Authrim IdP URL (required)
*/
issuer: string;
/**
* OAuth client ID (required)
*/
clientId: string;
/**
* Handoff verify endpoint
* @default '/auth/external/handoff/verify'
*/
verifyEndpoint?: string;
/**
* Error redirect path
* @default '/login'
*/
errorRedirect?: string;
/**
* Session manager options
*/
sessionOptions?: {
cookieName?: string; // default: 'authrim_session'
sameSite?: 'strict' | 'lax' | 'none'; // default: 'lax'
secure?: boolean; // default: true in production
path?: string; // default: '/'
maxAge?: number; // default: 7 days
httpOnly?: boolean; // default: true
};
}

Advanced Usage

Manual Token Verification

If you need more control, use verifyHandoffToken() in a load function:

src/routes/callback/+page.server.ts
import { verifyHandoffToken } from '@authrim/sveltekit/server';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const result = await verifyHandoffToken(event, {
issuer: import.meta.env.VITE_AUTHRIM_ISSUER,
clientId: import.meta.env.VITE_AUTHRIM_CLIENT_ID,
});
if (!result.success) {
throw redirect(302, '/login?error=' + result.error);
}
// Session is now saved in cookie
// Redirect to app
throw redirect(302, '/dashboard');
};

Custom Error Handling

createHandoffHandler({
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
errorRedirect: (error) => {
// Custom error handling
if (error === 'token_expired') {
return '/login?error=expired';
}
return '/login?error=handoff_failed';
},
})

Custom Session Storage

createHandoffHandler({
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
sessionOptions: {
cookieName: 'my_app_session',
sameSite: 'strict',
secure: true,
path: '/',
maxAge: 14 * 24 * 60 * 60, // 14 days
httpOnly: true,
},
})

Client-Side Usage

After successful handoff, access session data in your components:

<script lang="ts">
import { getAuthContext } from '@authrim/sveltekit';
const auth = getAuthContext();
// Note: Handoff is handled automatically by createHandoffHandler()
// in hooks.server.ts. This component only accesses the session.
</script>
{#if $auth.stores.isAuthenticated}
<div>
<p>Welcome, {$auth.stores.user?.name}!</p>
<button on:click={() => auth.signOut()}>Sign Out</button>
</div>
{:else}
<a href="/login">Sign In</a>
{/if}

Security Features

303 See Other Redirect

The handler uses 303 See Other instead of 302 Found:

// After verification, redirect with 303
throw redirect(303, cleanUrl.toString());

Why 303?

  • Converts POST to GET (proper semantic)
  • Prevents form resubmission on refresh
  • Recommended by HTTP spec for post-authentication redirects

Session cookies are configured with security best practices:

{
httpOnly: true, // Prevents XSS
secure: true, // HTTPS only (in production)
sameSite: 'lax', // CSRF protection
path: '/', // Application-wide
maxAge: 604800, // 7 days
}

State CSRF Protection

State validation happens server-side:

  • State is sent by Authorization Server
  • Verified during handoff token exchange
  • No client-side state management needed

Complete Example

1. Hooks Configuration

src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { createAuthHandle, createHandoffHandler } from '@authrim/sveltekit/server';
import { env } from '$env/dynamic/public';
const issuer = env.PUBLIC_AUTHRIM_ISSUER;
const clientId = env.PUBLIC_AUTHRIM_CLIENT_ID;
if (!issuer || !clientId) {
console.warn('[Authrim] Missing environment variables');
}
export const handle = sequence(
createAuthHandle({
issuer: issuer || '',
clientId: clientId || '',
callbackPaths: ['/callback'],
}),
createHandoffHandler({
issuer: issuer || '',
clientId: clientId || '',
errorRedirect: '/login?error=handoff_failed',
})
);

2. App Type Definitions

src/app.d.ts
import type { ServerAuthContext } from '@authrim/sveltekit/server';
declare global {
namespace App {
interface Locals {
auth?: ServerAuthContext;
}
}
}
export {};

3. Protected Route

src/routes/dashboard/+page.server.ts
import { requireAuth } from '@authrim/sveltekit/server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
// Automatically redirects to /login if not authenticated
const auth = await requireAuth(event);
return {
user: auth.user,
session: auth.session,
};
};

4. Dashboard Component

src/routes/dashboard/+page.svelte
<script lang="ts">
import { getAuthContext } from '@authrim/sveltekit';
import type { PageData } from './$types';
export let data: PageData;
const auth = getAuthContext();
async function handleSignOut() {
await auth.signOut();
// Automatically redirects to login
}
</script>
<div class="dashboard">
<h1>Dashboard</h1>
<p>Welcome, {data.user.name}!</p>
<p>Email: {data.user.email}</p>
<button on:click={handleSignOut}>Sign Out</button>
</div>

Error Handling

Server-Side Errors

// In +page.server.ts
export const load: PageServerLoad = async (event) => {
const result = await verifyHandoffToken(event);
if (!result.success) {
// Log error for monitoring
console.error('[Handoff] Verification failed:', result.error);
// Redirect to login with error message
throw redirect(303, `/login?error=${encodeURIComponent(result.error)}`);
}
return { session: result.session, user: result.user };
};

Client-Side Error Display

src/routes/login/+page.svelte
<script lang="ts">
import { page } from '$app/stores';
$: error = $page.url.searchParams.get('error');
const errorMessages: Record<string, string> = {
'handoff_failed': 'Authentication failed. Please try again.',
'token_expired': 'Session expired. Please sign in again.',
'invalid_state': 'Security check failed. Please try again.',
};
</script>
{#if error}
<div class="error">
{errorMessages[error] || 'An error occurred during authentication.'}
</div>
{/if}
<a href="/auth/login">Sign In</a>

Testing

Local Development

Terminal window
# Set environment variables
export VITE_AUTHRIM_ISSUER="https://auth.example.com"
export VITE_AUTHRIM_CLIENT_ID="dev-client-id"
# Start dev server
npm run dev
# Test handoff flow
# 1. Get handoff token from Authorization Server
# 2. Visit: http://localhost:5173/callback?handoff_token=xxx&state=yyy
# 3. Should automatically redirect to / with session

Testing with cURL

Terminal window
# Verify handoff token manually
curl -X POST https://auth.example.com/auth/external/handoff/verify \
-H "Content-Type: application/json" \
-d '{
"handoff_token": "your-handoff-token",
"state": "your-state",
"client_id": "your-client-id"
}'

Comparison: SvelteKit vs Vanilla JS

Feature@authrim/sveltekit@authrim/web
ProcessingServer-sideClient-side
Session storageCookielocalStorage
Type safetyFull TypeScriptFull TypeScript
SSR support✅ Yes❌ No
Setup complexityLow (hooks only)Medium (callback page)
SecurityHigher (HttpOnly cookies)Good (localStorage)
Browser compatibilityAll browsersAll browsers

Troubleshooting

Handoff Not Detected

Problem: Handler doesn’t trigger

Solution: Check URL parameters

// Add debug logging in hooks.server.ts
export const handle = sequence(
createAuthHandle({ ... }),
createHandoffHandler({ ... }),
async ({ event, resolve }) => {
console.log('URL params:', Object.fromEntries(event.url.searchParams));
return resolve(event);
}
);

Problem: Session cookie not saved

Solution: Check cookie configuration

createHandoffHandler({
// ...
sessionOptions: {
secure: false, // Set to false for local development (http)
sameSite: 'lax',
},
})

Infinite Redirects

Problem: Keeps redirecting

Solution: Check createAuthHandle callback paths

createAuthHandle({
// ...
callbackPaths: ['/callback'], // Should match your callback route
})