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:
- Click on the activity event to expand details
- Click the "Report" button (flag icon)
- Select a reason: "This wasn't me", "Suspicious activity", "Unknown device", "Unknown location", or "Other"
- Optionally add a description
- 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
| 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 overview | Yes |
/account/settings | Profile & privacy settings | Yes |
/account/security | 2FA & connected apps | Yes |
/account/connections | Connected OAuth accounts | Yes |
/account/activity | Activity log (audit events) | Yes |
/verify-email | Unified email verification (registration, link, change) | No |
/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 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:
- Navigate to Account → Security
- Click "Enable 2FA"
- Scan the QR code with an authenticator app
- Enter the 6-digit verification code
- 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:
- User requests deletion from Settings → Danger Zone
- Account is marked for deletion
- User can cancel within 30 days
- 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
| 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 |
NEXT_PUBLIC_API_URL | Public API URL for client | Yes |
NEXT_PUBLIC_ID_URL | Public Heimdall ID URL | Yes |
TWITCH_CLIENT_ID | Twitch OAuth client ID | Optional |
TWITCH_CLIENT_SECRET | Twitch OAuth client secret | Optional |
DISCORD_CLIENT_ID | Discord OAuth client ID | Optional |
DISCORD_CLIENT_SECRET | Discord OAuth client secret | Optional |
GOOGLE_CLIENT_ID | Google OAuth client ID | Optional |
GOOGLE_CLIENT_SECRET | Google OAuth client secret | Optional |
GITHUB_CLIENT_ID | GitHub OAuth client ID | Optional |
GITHUB_CLIENT_SECRET | GitHub OAuth client secret | Optional |
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
- Backend - Backend documentation
- Authentication Flow - Detailed auth flow
- Components - Shared component docs
- API Documentation - REST and GraphQL API reference