Skip to main content

ID

The ID app is the identity and authentication service for the platform, handling user registration, login, OAuth flows, account management, and GDPR-compliant privacy features.

Features

User Authentication

  • Email/password registration and login
  • Password recovery flow with email verification
  • Session management
  • Remember me functionality
  • Email verification for new accounts
  • Email change verification

Two-Factor Authentication (2FA)

Secure your account with TOTP-based two-factor authentication:

  • Authenticator app setup (Google Authenticator, Authy, etc.)
  • Backup codes for account recovery
  • Role-based 2FA requirements
  • Option to use backup code or TOTP for verification

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 with granular permissions
  • Token management
  • Connected apps management

Account Management

  • Profile settings (display name)
  • Password changes
  • Connected accounts (account linking)
  • Session management
  • Privacy mode toggle
  • GDPR-compliant data export
  • Account deletion with 30-day grace period

Account Linking

Link multiple authentication providers to a single account:

  • Twitch
  • Discord
  • YouTube (via Google)
  • GitHub

Connected Apps

Manage third-party applications that have access to your account:

  • View all connected apps with their permissions
  • Revoke individual app access
  • Revoke all app access at once
  • See when each app was connected

Active Sessions

Monitor and manage all your active login sessions:

  • View all active sessions across devices
  • See device type, browser, and OS information
  • View IP address and last activity time
  • See which authentication provider was used (Twitch, Discord, Email, etc.)
  • Identify current session
  • Revoke individual sessions remotely
  • Sign out of all other sessions at once

Activity Log

Track all account activity for security and transparency:

  • View complete history of account events
  • See authentication events (sign-ins, sign-outs, failed attempts)
  • Track session management actions
  • Monitor account changes (profile updates, password changes)
  • Review security settings changes (2FA enable/disable, backup codes regenerated)
  • See OAuth consent grants and revocations
  • Filter events by type (all, sign-ins, security, account changes)
  • Events include IP address, device information, and location (country, city)
  • Included in GDPR data export
  • Report suspicious activity with detailed reason selection

Tracked Event Types:

  • Authentication: Sign-in, sign-out, failed login attempts
  • Sessions: Creation, revocation, all sessions revoked
  • Security: 2FA enabled/disabled/verified, backup codes regenerated, password changes
  • Account: Profile updates, email changes, deletion requests
  • OAuth: Consent granted/revoked, token creation
  • Platform: Account linked/unlinked, primary account changed
  • Data: Data exports

Report Suspicious Activity: If you notice an activity you don't recognize, you can report it directly from the activity log:

  1. Click on the activity event to expand details
  2. Click the "Report" button (flag icon)
  3. Select a reason: "This wasn't me", "Suspicious activity", "Unknown device", "Unknown location", or "Other"
  4. Optionally add a description
  5. Submit the report

Reports are reviewed by our security team and help protect your account and the platform.

GDPR Compliance

Full GDPR compliance with user data rights:

  • Data Export: Download all your data in a structured format
  • Data Deletion: Request account deletion with 30-day grace period
  • Privacy Mode: Hide sensitive information in the UI
  • Consent Management: Granular OAuth consent controls

Pages & Routes

RouteDescriptionAuth Required
/Landing/redirectNo
/loginLogin pageNo
/registerRegistration pageNo
/forgot-passwordPassword recoveryNo
/bannedBanned user noticeNo
/accountAccount overviewYes
/account/settingsProfile & privacy settingsYes
/account/security2FA & connected appsYes
/account/connectionsConnected OAuth accountsYes
/account/activityActivity log (audit events)Yes
/verify-emailUnified email verification (registration, link, change)No
/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 proxy (Next.js 16 renamed middleware.ts to proxy.ts) protects authenticated routes:

// 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-id.session-token"
: "id.session-token",
});

const isAuthPage = pathname.startsWith("/login") ||
pathname.startsWith("/register");

if (!token && !isAuthPage && pathname.startsWith("/account")) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}

// Redirect authenticated users away from auth pages
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/account", request.url));
}

return NextResponse.next();
}

export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Two-Factor Authentication

Setting Up 2FA

Users can enable 2FA from the Security settings page:

  1. Navigate to Account → Security
  2. Click "Enable 2FA"
  3. Scan the QR code with an authenticator app
  4. Enter the 6-digit verification code
  5. Save the backup codes securely

Backup Codes

  • 10 single-use backup codes are generated during setup
  • Each code can only be used once
  • Users can regenerate codes (requires TOTP verification)
  • Warning is shown when fewer than 3 codes remain

Role-Based 2FA Requirements

Administrators can require 2FA for specific roles:

  • Roles can mandate 2FA for all assigned users
  • Users will be prompted to set up 2FA when required
  • Cannot disable 2FA if role requires it

Data Export

Users can export all their data in GDPR-compliant format:

// Request data export
const response = await fetch("/api/user/export");
const blob = await response.blob();
// Downloads as ZIP file with JSON data + PDF report

The export includes:

  • Profile information
  • Account settings
  • Connected accounts
  • Login history (with authentication provider used)
  • Active sessions (with device info and provider)
  • OAuth consents
  • Activity log (all audit events with anonymized IP addresses)

Account Deletion

Account deletion follows a 30-day grace period:

  1. User requests deletion from Settings → Danger Zone
  2. Account is marked for deletion
  3. User can cancel within 30 days
  4. After 30 days, account is anonymized

What happens during deletion:

  • Username is anonymized (deleted_xxxxx)
  • Profile picture is removed
  • OAuth connections are unlinked
  • All sessions are terminated
  • API keys are revoked
  • OAuth apps are deleted

Environment Variables

VariableDescriptionRequired
NEXTAUTH_URLThe base URL of the appYes
NEXTAUTH_SECRETSecret for signing tokensYes
API_URLRust API URLYes
NEXT_PUBLIC_API_URLPublic API URL for clientYes
NEXT_PUBLIC_ID_URLPublic Heimdall ID URLYes
TWITCH_CLIENT_IDTwitch OAuth client IDOptional
TWITCH_CLIENT_SECRETTwitch OAuth client secretOptional
DISCORD_CLIENT_IDDiscord OAuth client IDOptional
DISCORD_CLIENT_SECRETDiscord OAuth client secretOptional
GOOGLE_CLIENT_IDGoogle OAuth client IDOptional
GOOGLE_CLIENT_SECRETGoogle OAuth client secretOptional
GITHUB_CLIENT_IDGitHub OAuth client IDOptional
GITHUB_CLIENT_SECRETGitHub OAuth client secretOptional

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

Two-Factor Authentication

  • TOTP-based (RFC 6238)
  • Compatible with Google Authenticator, Authy, 1Password, etc.
  • Backup codes for recovery
  • Role-based enforcement

Session Security

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

OAuth Security

  • PKCE required for public clients
  • State parameter for CSRF protection
  • Redirect URI validation
  • Short-lived authorization codes
  • Consent screen for third-party apps

Data Privacy

  • Privacy mode to hide sensitive data in UI
  • GDPR-compliant data export
  • Account deletion with grace period
  • Audit logging of sensitive operations

Internationalization

Heimdall ID supports multiple languages:

  • English (default)
  • German

Translations are managed via next-intl with message files in the messages/ directory.

API Health Monitoring

The ID app includes real-time API health monitoring using the shared ApiHealthBanner component from @elcto/ui:

Integration

// src/components/ui/ApiHealthBanner.tsx
import {
ApiHealthBanner as BaseApiHealthBanner,
useApiHealth as baseUseApiHealth,
useShowApiHealthBanner,
type ApiHealthBannerLabels,
} from "@elcto/ui/components";
import { useTranslations } from "next-intl";

export function useApiHealth(baseInterval = 30000, maxInterval = 600000) {
return baseUseApiHealth({
baseInterval,
maxInterval,
apiUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000",
});
}

export function ApiHealthBanner({ apiHealth, show, fixedTop, onRefresh }) {
const t = useTranslations("apiHealth");

const labels: ApiHealthBannerLabels = {
unreachable: t("unreachable"),
serviceIssues: t("serviceIssues"),
database: t("database"),
redis: t("redis"),
websocket: t("websocket"),
checking: t("checking"),
refreshNow: t("refreshNow"),
};

return (
<BaseApiHealthBanner
apiHealth={apiHealth}
show={show}
labels={labels}
fixedTop={fixedTop}
onRefresh={onRefresh}
/>
);
}

Usage in Layout

// In your layout component
import { ApiHealthBanner, useApiHealth, useShowApiHealthBanner } from "@/components/ui/ApiHealthBanner";

function Layout({ children }) {
const { state: apiHealth, refresh: refreshApiHealth } = useApiHealth();
const showApiHealthBanner = useShowApiHealthBanner(apiHealth);

return (
<>
<ApiHealthBanner
apiHealth={apiHealth}
show={showApiHealthBanner}
fixedTop="4rem"
onRefresh={refreshApiHealth}
/>
<main className={showApiHealthBanner ? "pt-16" : "pt-0"}>
{children}
</main>
</>
);
}

Features

  • Exponential Backoff: Health checks use exponential backoff when failures occur to avoid overwhelming the server
  • Real-time Status: Shows API connectivity, database, Redis, and WebSocket status
  • Manual Refresh: Users can manually trigger a health check
  • i18n Support: All messages are translated via next-intl

Next Steps