Custom Server Quick Start
This guide walks through hosting Authagonal as a library in your own ASP.NET Core project, then customizing the login UI with your own React components.
Part 1: Server Setup
Create the project
dotnet new web -n MyAuthServer
cd MyAuthServer
# Add Authagonal packages (or project references for source builds)
dotnet add package Authagonal.Server
dotnet add package Authagonal.Storage
Your .csproj should contain:
<ItemGroup>
<PackageReference Include="Authagonal.Server" Version="*" />
<PackageReference Include="Authagonal.Storage" Version="*" />
</ItemGroup>
Configure Program.cs
The minimum setup is three calls: AddAuthagonal, UseAuthagonal, and MapAuthagonalEndpoints.
var builder = WebApplication.CreateBuilder(args);
// 1. Register custom services BEFORE AddAuthagonal — yours take precedence
builder.Services.AddSingleton<IAuthHook, AuditAuthHook>();
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();
// 2. Register Authagonal
builder.Services.AddAuthagonal(builder.Configuration);
var app = builder.Build();
// 3. Middleware + endpoints
app.UseAuthagonal();
app.MapAuthagonalEndpoints();
// 4. Serve the login SPA from wwwroot
app.MapFallbackToFile("index.html");
app.Run();
Configure appsettings.json
{
"Issuer": "https://auth.example.com",
"Storage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=..."
},
"Clients": [
{
"Id": "my-app",
"Name": "My Application",
"GrantTypes": ["authorization_code", "refresh_token"],
"RedirectUris": ["https://app.example.com/callback"],
"PostLogoutRedirectUris": ["https://app.example.com"],
"Scopes": ["openid", "profile", "email", "offline_access"],
"CorsOrigins": ["https://app.example.com"],
"RequirePkce": true,
"RequireSecret": false,
"AllowOfflineAccess": true
}
]
}
| Key | Description |
|---|---|
Issuer |
The public URL of your auth server. Used in tokens and OIDC discovery. |
Storage:ConnectionString |
Azure Table Storage connection string. |
Clients |
Array of OAuth clients seeded on startup. |
Extensibility points
Register your implementations before calling AddAuthagonal() — Authagonal uses TryAdd, so your registrations win.
| Interface | Purpose | Default |
|---|---|---|
IEmailService |
Send verification and password reset emails | No-op (silently discards) |
IAuthHook |
Gate or audit login, registration, and token events | No-op |
IProvisioningOrchestrator |
Provision users into downstream apps at authorize time | TCC provisioning |
ISecretProvider |
Resolve client secrets | Plaintext (or Key Vault with SecretProvider:VaultUri) |
Example: audit hook
using Authagonal.Core.Services;
public class AuditAuthHook(ILogger<AuditAuthHook> logger) : IAuthHook
{
public Task OnUserAuthenticatedAsync(string userId, string email,
string method, string? clientId = null, CancellationToken ct = default)
{
logger.LogInformation("Login: {Email} via {Method}", email, method);
return Task.CompletedTask;
}
public Task OnUserCreatedAsync(string userId, string email,
string createdVia, CancellationToken ct = default)
{
logger.LogInformation("New user: {Email} via {Via}", email, createdVia);
return Task.CompletedTask;
}
public Task OnLoginFailedAsync(string email, string reason,
CancellationToken ct = default)
{
logger.LogWarning("Failed login: {Email} — {Reason}", email, reason);
return Task.CompletedTask;
}
public Task OnTokenIssuedAsync(string? subjectId, string clientId,
string grantType, CancellationToken ct = default)
{
logger.LogInformation("Token issued: {ClientId} ({GrantType})", clientId, grantType);
return Task.CompletedTask;
}
}
Example: email service
using Authagonal.Core.Services;
public class ConsoleEmailService(ILogger<ConsoleEmailService> logger) : IEmailService
{
public Task SendVerificationEmailAsync(string email, string callbackUrl,
CancellationToken ct = default)
{
logger.LogInformation("Verify email: {Url}", callbackUrl);
return Task.CompletedTask;
}
public Task SendPasswordResetEmailAsync(string email, string callbackUrl,
CancellationToken ct = default)
{
logger.LogInformation("Reset password: {Url}", callbackUrl);
return Task.CompletedTask;
}
}
Add custom endpoints
You can add your own endpoints alongside Authagonal’s:
app.MapGet("/custom/health", () => Results.Ok(new { status = "healthy" }));
Disable admin API
For public-facing deployments, disable the admin endpoints:
{
"AdminApi": {
"Enabled": false
}
}
Run it
dotnet run
The server starts on the configured URL, serving the OIDC discovery document at /.well-known/openid-configuration, the login UI at /login, and all auth/admin APIs.
Part 2: Custom Login UI
The default login SPA works out of the box, but you can replace it with your own React app that imports components and API clients from the @authagonal/login npm package.
Scaffold the frontend
mkdir login-app && cd login-app
npm init -y
npm install react react-dom react-router-dom @authagonal/login
npm install -D vite @vitejs/plugin-react typescript @types/react @types/react-dom
What the npm package exports
// Components — use as-is or as reference
import {
AuthLayout,
LoginPage,
ForgotPasswordPage,
ResetPasswordPage,
MfaChallengePage,
MfaSetupPage,
RegisterPage,
ConsentPage,
GrantsPage,
DevicePage,
App, // Standalone SPA with full routing
} from '@authagonal/login';
// UI primitives
import {
Button, Input, Label, Card, Alert, Separator, cn,
} from '@authagonal/login';
// API clients — call from your custom pages
import {
login, logout, ssoCheck, forgotPassword, resetPassword,
getSession, getProviders, getPasswordPolicy,
mfaVerify, mfaStatus, mfaTotpSetup, mfaTotpConfirm,
mfaWebAuthnSetup, mfaWebAuthnConfirm, mfaRecoveryGenerate,
mfaDeleteCredential,
ApiRequestError,
} from '@authagonal/login';
// Branding
import {
loadBranding, useBranding, BrandingContext, resolveLocalized,
} from '@authagonal/login';
// i18n — always import from this package, not react-i18next directly
import { useTranslation, i18n } from '@authagonal/login';
// Styles
import '@authagonal/login/styles.css';
// Types
import type {
BrandingConfig, LocalizedString, LoginResponse,
SessionResponse, ExternalProvider, PasswordPolicyResponse,
MfaStatusResponse, MfaTotpSetupResponse,
} from '@authagonal/login';
Entry point (main.tsx)
Load branding from the server and wrap your app in the branding context:
import { createRoot } from 'react-dom/client';
import { loadBranding, BrandingContext } from '@authagonal/login';
import '@authagonal/login/styles.css';
import App from './App';
loadBranding().then((config) => {
document.title = `Sign In — ${config.appName}`;
createRoot(document.getElementById('root')!).render(
<BrandingContext.Provider value={config}>
<App />
</BrandingContext.Provider>
);
});
Routing (App.tsx)
Mix custom pages with the base package pages:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import {
ForgotPasswordPage, ResetPasswordPage, ConsentPage, DevicePage, GrantsPage,
} from '@authagonal/login';
import MyLoginPage from './MyLoginPage';
import MyLayout from './MyLayout';
export default function App() {
return (
<BrowserRouter>
<MyLayout>
<Routes>
<Route path="/login" element={<MyLoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/consent" element={<ConsentPage />} />
<Route path="/device" element={<DevicePage />} />
<Route path="/grants" element={<GrantsPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</MyLayout>
</BrowserRouter>
);
}
Custom login page
Build your own login form using the API clients from the npm package:
import { useState } from 'react';
import { login, ssoCheck, ApiRequestError, useBranding } from '@authagonal/login';
export default function MyLoginPage() {
const branding = useBranding();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login(email, password);
// Login sets a cookie — redirect to the return URL
const params = new URLSearchParams(window.location.search);
window.location.href = params.get('returnUrl') || '/';
} catch (err) {
if (err instanceof ApiRequestError) {
setError(err.message || 'Login failed');
}
}
};
return (
<form onSubmit={handleSubmit}>
<h1>Sign in to {branding.appName}</h1>
{error && <p className="error">{error}</p>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit">Sign in</button>
</form>
);
}
Custom layout
Wrap the base AuthLayout to add your own branding:
import { AuthLayout } from '@authagonal/login';
export default function MyLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AuthLayout>{children}</AuthLayout>
<footer>
© {new Date().getFullYear()} My Company —
<a href="/terms">Terms</a> | <a href="/privacy">Privacy</a>
</footer>
</>
);
}
Branding (wwwroot/branding.json)
Configure the login UI appearance without rebuilding:
{
"appName": "My App",
"logoUrl": "/logo.svg",
"primaryColor": "#059669",
"supportEmail": "support@example.com",
"showForgotPassword": true,
"customCssUrl": "/custom.css"
}
Vite config
Proxy API calls to the backend during development:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
proxy: {
'/api': { target: 'http://localhost:5000', changeOrigin: true },
'/connect': { target: 'http://localhost:5000', changeOrigin: true },
'/saml': { target: 'http://localhost:5000', changeOrigin: true },
'/oidc': { target: 'http://localhost:5000', changeOrigin: true },
},
},
});
Build and serve
Add a build target to your .csproj to automatically build the SPA and copy it to wwwroot:
<Target Name="BuildLoginApp" BeforeTargets="Build" Condition="!Exists('wwwroot/index.html')">
<Exec Command="npm ci" WorkingDirectory="login-app" />
<Exec Command="npm run build" WorkingDirectory="login-app" />
<ItemGroup>
<LoginAppFiles Include="login-app/dist/**/*" />
</ItemGroup>
<Copy SourceFiles="@(LoginAppFiles)" DestinationFolder="wwwroot/%(RecursiveDir)" />
</Target>
Now dotnet build builds both the .NET server and the React SPA, and dotnet run serves everything from a single process.