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:
// middleware.ts
export default auth((req) => {
// Check if user is authenticated
if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) {
// Redirect to login with callback URL
const loginUrl = new URL("/auth/login", req.nextUrl.origin);
loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname);
return Response.redirect(loginUrl);
}
});
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
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 Dashboard - Dashboard documentation
- Heimdall ID - Identity service documentation
- API Authentication - API auth details