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:

// 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} />;
}

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:

  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