Skip to main content

Authentication Flow

This document explains how authentication works across the Heimdall platform, including the interaction between Heimdall ID, the Backend Dashboard, and the Rust API.

Overview

┌──────────────────────────────────────────────────────────────────────────┐
│ Authentication Flow │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ User │
│ │ │
│ │ 1. Access Backend Dashboard │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Backend Dashboard│ │
│ │ (Next.js) │ │
│ └────────┬────────┘ │
│ │ │
│ │ 2. Redirect to Heimdall ID │
│ │ (OAuth Authorization) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Heimdall ID │ │
│ │ (Next.js) │ │
│ └────────┬────────┘ │
│ │ │
│ │ 3. User logs in (or │
│ │ creates account) │
│ │ │
│ │ 4. User consents to │
│ │ permissions │
│ │ │
│ │ 5. Redirect back with │
│ │ authorization code │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Backend Dashboard│ │
│ │ (Next.js) │ │
│ └────────┬────────┘ │
│ │ │
│ │ 6. Exchange code for tokens │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Rust API │ │
│ │ /v1/oauth │ │
│ └────────┬────────┘ │
│ │ │
│ │ 7. Return access & refresh tokens │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Backend Dashboard│ │
│ │ (Session Set) │ │
│ └─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘

Step-by-Step Flow

1. User Accesses Protected Page

When a user tries to access a protected page on the Backend Dashboard, the proxy (Next.js 16 renamed middleware.ts to proxy.ts) checks authentication:

// proxy.ts (Next.js 16+)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";

export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;

const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
cookieName: process.env.NODE_ENV === "production"
? "__Secure-backend.session-token"
: "backend.session-token",
});

// Check if user is authenticated
if (!token && pathname.startsWith("/dashboard")) {
// Redirect to login with callback URL
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}

return NextResponse.next();
}

2. Redirect to Heimdall ID

The login page initiates the OAuth flow:

// src/app/auth/login/page.tsx
import { signIn } from "next-auth/react";

export default function LoginPage({ searchParams }) {
const callbackUrl = searchParams.callbackUrl || "/dashboard";

const handleLogin = () => {
signIn("heimdall", { callbackUrl });
};

return (
<Button onClick={handleLogin}>
Sign in with Heimdall
</Button>
);
}

NextAuth constructs the authorization URL:

https://id.elcto.com/oauth/authorize?
client_id=backend-dashboard
&redirect_uri=https://backend.elcto.com/api/auth/callback/heimdall
&response_type=code
&scope=openid profile email
&state=abc123
&code_challenge=xyz789
&code_challenge_method=S256

3. User Authentication at Heimdall ID

Heimdall ID handles the login:

// platform/id/src/app/login/page.tsx
export default function LoginPage({ searchParams }) {
const { callbackUrl } = searchParams;

const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target);

const result = await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirect: false
});

if (result?.ok) {
// Continue OAuth flow if there's a pending authorization
if (callbackUrl?.includes("oauth/authorize")) {
router.push(callbackUrl);
}
}
};

return <LoginForm onSubmit={handleSubmit} />;
}

If the user hasn't authorized the app before:

// platform/id/src/app/oauth/consent/page.tsx
export default function ConsentPage({ searchParams }) {
const { client_id, scope, redirect_uri, state, code_challenge } = searchParams;

const handleAllow = async () => {
// Generate authorization code
const code = await generateAuthCode({
userId: session.user.id,
clientId: client_id,
scope: scope.split(" "),
codeChallenge: code_challenge
});

// Redirect back to client
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set("code", code);
redirectUrl.searchParams.set("state", state);
router.push(redirectUrl.toString());
};

return (
<div>
<h1>{clientName} wants to access your account</h1>

<h2>Requested permissions:</h2>
<ul>
{scopes.map(scope => (
<li key={scope}>{scopeDescriptions[scope]}</li>
))}
</ul>

<Button onClick={handleDeny}>Deny</Button>
<Button onClick={handleAllow}>Allow</Button>
</div>
);
}

5. Redirect with Authorization Code

After consent, Heimdall ID redirects back:

https://backend.elcto.com/api/auth/callback/heimdall?
code=auth_code_here
&state=abc123

6. Exchange Code for Tokens

NextAuth handles the callback and exchanges the code:

// platform/backend/src/lib/auth/heimdall-provider.ts
export const HeimdallProvider = {
id: "heimdall",
name: "Heimdall",
type: "oauth",

authorization: {
url: `${process.env.HEIMDALL_ID_URL}/oauth/authorize`,
params: {
scope: "openid profile email"
}
},

token: {
url: `${process.env.API_URL}/v1/oauth/token`,
async request({ params, provider }) {
const response = await fetch(provider.token.url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: params.code,
redirect_uri: provider.callbackUrl,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
code_verifier: params.code_verifier
})
});

return response.json();
}
},

userinfo: {
url: `${process.env.API_URL}/v1/oauth/userinfo`
},

profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture
};
}
};

7. Session Creation

NextAuth creates a session with the tokens:

// platform/backend/src/lib/auth.ts
export const authOptions = {
providers: [HeimdallProvider],

callbacks: {
async jwt({ token, account, profile }) {
if (account) {
// Store tokens in JWT
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = account.expires_at * 1000;
token.id = profile.sub;
}

// Return previous token if not expired
if (Date.now() < token.accessTokenExpires) {
return token;
}

// Refresh token if expired
return refreshAccessToken(token);
},

async session({ session, token }) {
session.accessToken = token.accessToken;
session.user.id = token.id;
return session;
}
},

session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60 // 30 days
}
};

Token Refresh Flow

┌────────────────────────────────────────────────────────────────┐
│ Token Refresh Flow │
├────────────────────────────────────────────────────────────────┤
│ │
│ Backend Dashboard │
│ │ │
│ │ 1. Access token expired │
│ │ │
│ │ 2. Use refresh token │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Rust API │ │
│ │ /v1/oauth/token │ │
│ └────────┬────────┘ │
│ │ │
│ │ 3. Validate refresh token │
│ │ │
│ │ 4. Issue new access token │
│ │ (and optionally new refresh token) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Backend Dashboard│ │
│ │ (Token Updated) │ │
│ └─────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
async function refreshAccessToken(token) {
try {
const response = await fetch(`${process.env.API_URL}/v1/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: token.refreshToken,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
})
});

const tokens = await response.json();

if (!response.ok) {
throw tokens;
}

return {
...token,
accessToken: tokens.access_token,
accessTokenExpires: Date.now() + tokens.expires_in * 1000,
refreshToken: tokens.refresh_token ?? token.refreshToken
};
} catch (error) {
return {
...token,
error: "RefreshAccessTokenError"
};
}
}

API Authentication

Once authenticated, requests to the Rust API include the access token:

// GraphQL request
const client = new ApolloClient({
uri: `${process.env.NEXT_PUBLIC_API_URL}/v1/gql`,
cache: new InMemoryCache(),
headers: {
Authorization: `Bearer ${session.accessToken}`
}
});

// REST request
const response = await fetch(`${process.env.API_URL}/v1/gps/current`, {
headers: {
Authorization: `Bearer ${session.accessToken}`
}
});

The Rust API validates the token:

// platform/api/src/middleware/auth.rs
pub async fn auth_middleware(
req: HttpRequest,
next: Next<BoxBody>,
) -> Result<ServiceResponse<BoxBody>, Error> {
let token = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));

match token {
Some(token) => {
// Validate token (JWT or database lookup)
let user = validate_token(token).await?;
req.extensions_mut().insert(user);
next.call(req).await
}
None => Err(ApiError::Unauthorized.into())
}
}

Account Linking Flow

Users can link multiple providers to their account:

┌────────────────────────────────────────────────────────────────┐
│ Account Linking Flow │
├────────────────────────────────────────────────────────────────┤
│ │
│ User (logged in) │
│ │ │
│ │ 1. Click "Link Twitch Account" │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Heimdall ID │ │
│ │ /account │ │
│ └────────┬────────┘ │
│ │ │
│ │ 2. Redirect to Twitch OAuth │
│ │ (with linking state) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Twitch │ │
│ │ OAuth │ │
│ └────────┬────────┘ │
│ │ │
│ │ 3. User authorizes │
│ │ │
│ │ 4. Redirect back with code │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Heimdall ID │ │
│ │ /api/auth/link │ │
│ └────────┬────────┘ │
│ │ │
│ │ 5. Exchange code, get Twitch profile │
│ │ │
│ │ 6. Link Twitch account to user │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Heimdall ID │ │
│ │ /account/linked │ │
│ └─────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘

Security Considerations

Session Isolation

Each application uses unique cookie name prefixes to prevent session sharing between apps. This ensures that logging into one app (e.g., Heimdall ID) does not automatically log the user into another app (e.g., Backend Dashboard).

AppDevelopment CookieProduction Cookie
Heimdall IDid.session-token__Secure-id.session-token
Backend Dashboardbackend.session-token__Secure-backend.session-token
Policiespolicies.session-token__Secure-policies.session-token

When using getToken() in the proxy, specify the cookieName parameter:

// proxy.ts (Next.js 16+)
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
cookieName: process.env.NODE_ENV === "production"
? "__Secure-{app}.session-token"
: "{app}.session-token",
});

Token Storage

  • Access tokens are stored in HTTP-only, secure cookies
  • Refresh tokens are stored server-side in the JWT
  • Tokens are never exposed to client-side JavaScript

PKCE

All OAuth flows use PKCE (Proof Key for Code Exchange):

function generatePKCE() {
const verifier = generateRandomString(64);
const challenge = base64url(sha256(verifier));
return { verifier, challenge };
}

State Parameter

CSRF protection via state parameter:

function generateState(userId: string) {
return sign(
{ userId, timestamp: Date.now() },
process.env.NEXTAUTH_SECRET
);
}

function verifyState(state: string) {
try {
const payload = verify(state, process.env.NEXTAUTH_SECRET);
if (Date.now() - payload.timestamp > 10 * 60 * 1000) {
return null; // Expired (10 minutes)
}
return payload.userId;
} catch {
return null;
}
}

Token Validation

The Rust API validates tokens on every request:

  1. Check token format (JWT or API key)
  2. Verify signature (for JWT)
  3. Check expiration
  4. Verify scopes/permissions
  5. Rate limit check

Error Handling

Common Auth Errors

ErrorCauseSolution
invalid_tokenToken expired or invalidRefresh token or re-login
insufficient_scopeMissing required permissionRequest additional scopes
access_deniedUser denied consentHandle gracefully, show message
invalid_grantAuth code expiredRestart OAuth flow

Error Handling Example

// Handle refresh token errors
if (session?.error === "RefreshAccessTokenError") {
// Force sign out
signOut({ callbackUrl: "/auth/login" });
}

// Handle API errors
try {
const response = await fetch("/api/protected");
if (response.status === 401) {
// Token invalid, redirect to login
router.push("/auth/login");
}
} catch (error) {
console.error("API error:", error);
}

Next Steps