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} />;
}
4. Consent Screen
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).
| App | Development Cookie | Production Cookie |
|---|---|---|
| Heimdall ID | id.session-token | __Secure-id.session-token |
| Backend Dashboard | backend.session-token | __Secure-backend.session-token |
| Policies | policies.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:
- Check token format (JWT or API key)
- Verify signature (for JWT)
- Check expiration
- Verify scopes/permissions
- Rate limit check
Error Handling
Common Auth Errors
| Error | Cause | Solution |
|---|---|---|
invalid_token | Token expired or invalid | Refresh token or re-login |
insufficient_scope | Missing required permission | Request additional scopes |
access_denied | User denied consent | Handle gracefully, show message |
invalid_grant | Auth code expired | Restart 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
- Backend - Backend documentation
- ID - Identity service documentation
- API Authentication - API auth details