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
| Route | Description | Auth Required |
|---|---|---|
/ | Landing/redirect | No |
/login | Login page | No |
/register | Registration page | No |
/forgot-password | Password recovery | No |
/banned | Banned user notice | No |
/account | Account settings | Yes |
/account/connections | Connected accounts | Yes |
/oauth/authorize | OAuth authorization | Yes |
/oauth/consent | OAuth consent screen | Yes |
OAuth Provider Implementation
Authorization Endpoint
GET /oauth/authorize
Query parameters:
client_id- OAuth client IDredirect_uri- Callback URLresponse_type- Must be "code"scope- Requested scopesstate- CSRF protectioncode_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
Consent Flow
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
| Variable | Description | Required |
|---|---|---|
NEXTAUTH_URL | The base URL of the app | Yes |
NEXTAUTH_SECRET | Secret for signing tokens | Yes |
API_URL | Rust API URL | Yes |
TWITCH_CLIENT_ID | Twitch OAuth client ID | Yes |
TWITCH_CLIENT_SECRET | Twitch OAuth client secret | Yes |
SMTP_HOST | Email server host | For password reset |
SMTP_PORT | Email server port | For password reset |
SMTP_USER | Email server username | For password reset |
SMTP_PASS | Email server password | For 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
- Backend Dashboard - Admin dashboard documentation
- Authentication Flow - Detailed auth flow
- Components - Shared component docs