Skip to main content

Heimdall ID

Heimdall ID is the identity and authentication service for the platform, handling user registration, login, OAuth flows, and account management.

Features

User Authentication

  • Email/password registration and login
  • Password recovery flow
  • Session management
  • Remember me functionality

OAuth Provider

Heimdall ID acts as an OAuth 2.0 provider, allowing third-party applications (including the Backend Dashboard) to authenticate users:

  • Authorization Code flow
  • PKCE support
  • Consent screen
  • Token management

Account Management

  • Profile settings
  • Password changes
  • Connected accounts (account linking)
  • Session management
  • Account deletion

Account Linking

Link multiple authentication providers to a single account:

  • Twitch
  • Discord (planned)
  • Google (planned)

Pages & Routes

RouteDescriptionAuth Required
/Landing/redirectNo
/loginLogin pageNo
/registerRegistration pageNo
/forgot-passwordPassword recoveryNo
/bannedBanned user noticeNo
/accountAccount settingsYes
/account/connectionsConnected accountsYes
/oauth/authorizeOAuth authorizationYes
/oauth/consentOAuth consent screenYes

OAuth Provider Implementation

Authorization Endpoint

GET /oauth/authorize

Query parameters:

  • client_id - OAuth client ID
  • redirect_uri - Callback URL
  • response_type - Must be "code"
  • scope - Requested scopes
  • state - CSRF protection
  • code_challenge - PKCE challenge (optional)
  • code_challenge_method - PKCE method (optional)

Token Endpoint

The token endpoint is handled by the Rust API:

POST /v1/oauth/token

When a user authorizes an application for the first time, they're shown a consent screen:

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

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

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

<form action="/api/oauth/authorize" method="POST">
<input type="hidden" name="client_id" value={client_id} />
<input type="hidden" name="scope" value={scope} />
<input type="hidden" name="redirect_uri" value={redirect_uri} />

<Button type="submit" name="action" value="deny">
Deny
</Button>
<Button type="submit" name="action" value="allow">
Allow
</Button>
</form>
</div>
);
}

Account Linking

Linking a New Account

// src/app/api/auth/link/[provider]/route.ts
export async function GET(
request: Request,
{ params }: { params: { provider: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.redirect("/login");
}

// Initiate OAuth flow with provider
const authUrl = buildAuthUrl(params.provider, {
state: generateState(session.user.id),
redirect_uri: `/api/auth/link/${params.provider}/callback`
});

return NextResponse.redirect(authUrl);
}

Callback Handler

// src/app/api/auth/link/[provider]/callback/route.ts
export async function GET(
request: Request,
{ params }: { params: { provider: string } }
) {
const { code, state } = parseQueryParams(request);

// Verify state and get user ID
const userId = verifyState(state);
if (!userId) {
return NextResponse.redirect("/account/connections?error=invalid_state");
}

// Exchange code for tokens
const tokens = await exchangeCode(params.provider, code);

// Get provider profile
const profile = await getProviderProfile(params.provider, tokens);

// Link account in database
await linkAccount({
userId,
provider: params.provider,
providerAccountId: profile.id,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token
});

return NextResponse.redirect("/account/connections?success=true");
}

Managing Connected Accounts

// src/app/api/user/accounts/route.ts
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const accounts = await getLinkedAccounts(session.user.id);

return NextResponse.json({ accounts });
}
// src/app/api/user/accounts/[accountId]/route.ts
export async function DELETE(
request: Request,
{ params }: { params: { accountId: string } }
) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await unlinkAccount(session.user.id, params.accountId);

return NextResponse.json({ success: true });
}

Components

Login Form

// src/app/login/page.tsx
"use client";

import { signIn } from "next-auth/react";
import { FloatingInput } from "@/components/FloatingInput";
import { Alert } from "@/components/Alert";

export default function LoginPage() {
const [error, setError] = useState<string | null>(null);

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

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

if (result?.error) {
setError("Invalid email or password");
} else {
router.push("/account");
}
};

return (
<form onSubmit={handleSubmit}>
{error && <Alert type="error">{error}</Alert>}

<FloatingInput
label="Email"
name="email"
type="email"
required
/>

<FloatingInput
label="Password"
name="password"
type="password"
required
/>

<button type="submit">Sign In</button>

<a href="/forgot-password">Forgot password?</a>

<div>
<span>Or continue with</span>
<button type="button" onClick={() => signIn("twitch")}>
Twitch
</button>
</div>
</form>
);
}

Registration Form

// src/app/register/page.tsx
"use client";

export default function RegisterPage() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);

const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: formData.get("email"),
password: formData.get("password"),
name: formData.get("name")
})
});

if (response.ok) {
// Auto sign in after registration
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
callbackUrl: "/account"
});
}
};

return (
<form onSubmit={handleSubmit}>
<FloatingInput label="Name" name="name" required />
<FloatingInput label="Email" name="email" type="email" required />
<FloatingInput label="Password" name="password" type="password" required />
<FloatingInput
label="Confirm Password"
name="confirmPassword"
type="password"
required
/>

<button type="submit">Create Account</button>

<a href="/login">Already have an account? Sign in</a>
</form>
);
}

Connections Page

// src/app/account/connections/page.tsx
"use client";

import { useEffect, useState } from "react";

interface LinkedAccount {
id: string;
provider: string;
providerAccountId: string;
username: string;
linkedAt: string;
}

export default function ConnectionsPage() {
const [accounts, setAccounts] = useState<LinkedAccount[]>([]);

useEffect(() => {
fetch("/api/user/accounts")
.then((res) => res.json())
.then((data) => setAccounts(data.accounts));
}, []);

const handleUnlink = async (accountId: string) => {
await fetch(`/api/user/accounts/${accountId}`, {
method: "DELETE"
});
setAccounts(accounts.filter((a) => a.id !== accountId));
};

const handleLink = (provider: string) => {
window.location.href = `/api/auth/link/${provider}`;
};

return (
<div>
<h1>Connected Accounts</h1>

<h2>Linked Accounts</h2>
{accounts.map((account) => (
<div key={account.id}>
<span>{account.provider}</span>
<span>{account.username}</span>
<button onClick={() => handleUnlink(account.id)}>Unlink</button>
</div>
))}

<h2>Link New Account</h2>
<button onClick={() => handleLink("twitch")}>Link Twitch</button>
</div>
);
}

Protected Routes

The middleware protects authenticated routes:

// middleware.ts
import { auth } from "@/lib/auth";

export default auth((req) => {
const isAuthPage = req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/register");

if (!req.auth && !isAuthPage && req.nextUrl.pathname.startsWith("/account")) {
const newUrl = new URL("/login", req.nextUrl.origin);
newUrl.searchParams.set("callbackUrl", req.nextUrl.pathname);
return Response.redirect(newUrl);
}

// Redirect authenticated users away from auth pages
if (req.auth && isAuthPage) {
return Response.redirect(new URL("/account", req.nextUrl.origin));
}
});

export const config = {
matcher: ["/account/:path*", "/login", "/register", "/oauth/:path*"]
};

Environment Variables

VariableDescriptionRequired
NEXTAUTH_URLThe base URL of the appYes
NEXTAUTH_SECRETSecret for signing tokensYes
API_URLRust API URLYes
TWITCH_CLIENT_IDTwitch OAuth client IDYes
TWITCH_CLIENT_SECRETTwitch OAuth client secretYes
SMTP_HOSTEmail server hostFor password reset
SMTP_PORTEmail server portFor password reset
SMTP_USEREmail server usernameFor password reset
SMTP_PASSEmail server passwordFor password reset

Development

cd platform/id

# Install dependencies
pnpm install

# Run development server
pnpm dev

# Build for production
pnpm build

# Start production server
pnpm start

# Run linting
pnpm lint

# Run E2E tests
pnpm test:e2e

Security Considerations

Password Requirements

  • Minimum 8 characters
  • At least one uppercase letter
  • At least one lowercase letter
  • At least one number
  • Common passwords are rejected

Session Security

  • HTTP-only cookies
  • Secure flag in production
  • SameSite=Lax
  • Token rotation on use

OAuth Security

  • PKCE required for public clients
  • State parameter for CSRF protection
  • Redirect URI validation
  • Short-lived authorization codes

Next Steps