Authentication¶
Stack¶
- Keycloak - Identity Provider (external service, standalone repo)
- OIDC - OpenID Connect protocol
- JWT Bearer - token-based authentication
- oidc-client-ts - frontend OIDC integration (wrapped in SolidJS context)
Authentication Flow¶
sequenceDiagram
participant U as User
participant W as Webapp
participant K as Keycloak
participant G as Gateway
participant A as API
U->>W: Opens application
W->>K: Redirect to login (Authorization Code + PKCE)
U->>K: Enters credentials
K->>W: Authorization code
W->>K: Exchange code for tokens
K->>W: Access Token + Refresh Token
W->>W: Store in sessionStorage (oidc-client-ts)
W->>G: API Request + Bearer token
G->>A: Forward with token
A->>A: Validate JWT signature & claims
A->>A: Extract user via ICurrentUserService
A-->>G: Response
G-->>W: Response
Keycloak Configuration¶
Realm: recron¶
| Setting | Value |
|---|---|
| Realm name | recron |
| Login theme | custom (keywind, from Keycloak repo) |
| Access token lifespan | 5 min |
| Refresh token lifespan | 30 min |
Clients¶
| Client | Type | Purpose |
|---|---|---|
| frontend | public | Frontend SPA (Authorization Code + PKCE) |
| scalar | confidential | Scalar API Reference (Client Credentials) |
Roles¶
| Role | Description |
|---|---|
| admin | Full access, system management |
| moderator | Approve/reject proposed questions |
| user | Standard user access |
Backend Configuration¶
Aspire AppHost¶
Keycloak runs as an external service (standalone Keycloak repo). The Recron AppHost only passes
the Keycloak authority URL to API services:
var keycloakAuthority = builder
.AddParameter("keycloak-authority");
// Each API receives the authority URL as an environment variable
apiService
.WithEnvironment("Keycloak__Authority", keycloakAuthority);
For local development, set the authority in appsettings.json:
Note: You must run Keycloak separately from the
Keycloakrepo before starting Recron locally.
API Authentication Setup¶
// In Extensions.Infrastructure NuGet — AuthExtensions.cs
// Standard JWT Bearer with Keycloak as the authority
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Authority = keycloakOptions.AuthorityUrl;
options.Audience = "account";
options.RequireHttpsMetadata = keycloakOptions.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = keycloakOptions.AuthorityUrl,
ValidAudience = "account",
RoleClaimType = ClaimTypes.Role,
};
});
// Claims transformation to map Keycloak roles to standard .NET role claims
services.AddTransient<IClaimsTransformation, KeycloakClaimsTransformation>();
// Authorization policies
services.AddAuthorizationBuilder()
.AddPolicy("RequireAdmin", policy => policy.RequireRole(Roles.Admin))
.AddPolicy("RequireModerator", policy => policy.RequireRole(Roles.Moderator))
.AddPolicy("RequireAdminOrModerator", policy =>
policy.RequireRole(Roles.Admin, Roles.Moderator));
Claims Transformation¶
Keycloak claims are transformed to standard .NET claims:
public class KeycloakClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// Map Keycloak realm_access.roles to ClaimTypes.Role
// Map sub to ClaimTypes.NameIdentifier
// Map preferred_username to ClaimTypes.Name
}
}
Current User Service¶
public interface ICurrentUserService
{
Guid? UserId { get; }
string? UserName { get; }
IReadOnlyList<string> Roles { get; }
bool IsInRole(string role);
}
// Implementation extracts from HttpContext.User claims
public class CurrentUserService(IHttpContextAccessor httpContextAccessor)
: ICurrentUserService
{
public Guid? UserId => Guid.TryParse(
httpContextAccessor.HttpContext?.User
.FindFirst(ClaimTypes.NameIdentifier)?.Value,
out var id) ? id : null;
public IReadOnlyList<string> Roles =>
httpContextAccessor.HttpContext?.User
.FindAll(ClaimTypes.Role)
.Select(c => c.Value)
.ToList() ?? [];
}
Endpoint Protection¶
// Require any authenticated user
app.MapPost("/questions/propose", ...)
.RequireAuthorization();
// Require specific role
app.MapPost("/questions/approve", ...)
.RequireAuthorization(policy => policy.RequireRole(Roles.Moderator));
Handler Usage¶
public sealed class ProposeQuestionHandler(
ApplicationDbContext dbContext,
ICurrentUserService userService)
{
public async Task ExecuteAsync(ProposeQuestionCommand command, CancellationToken ct)
{
// For commands - require authentication
var userId = userService.UserId
?? throw new UnauthorizedAccessException();
// For queries - allow anonymous (use empty guid)
var userId = userService.UserId ?? Guid.Empty;
}
}
Frontend Configuration¶
Auth Provider (oidc-client-ts + SolidJS context)¶
Custom AuthProvider wrapping oidc-client-ts UserManager in a SolidJS context (src/store/auth.tsx):
const userManager = new UserManager({
authority: import.meta.env.VITE_AUTH_ISSUER,
client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
redirect_uri: `${import.meta.env.VITE_AUTH_URL}/settings`,
post_logout_redirect_uri: import.meta.env.VITE_AUTH_URL,
userStore: new WebStorageStateStore({ store: sessionStorage }),
automaticSilentRenew: true,
});
// Provider wraps the app
<AuthProvider>
<App />
</AuthProvider>;
The provider handles:
- OIDC callback processing (
signinRedirectCallback) - Silent token renewal via
UserManagerevents - Token expiration detection
- Error state management
useAuth Hook¶
const auth = useAuth();
// All values are SolidJS accessors (call them to read)
auth.isAuthenticated(); // boolean
auth.isLoading(); // boolean
auth.user(); // User | null
auth.accessToken(); // string | undefined
auth.roles(); // string[]
auth.isAdmin(); // boolean
auth.isModerator(); // boolean
auth.isAdminOrModerator(); // boolean
// Methods
auth.signIn(); // redirect to Keycloak login
auth.signOut(); // redirect to Keycloak logout
auth.signInSilent(); // silent token renewal
auth.hasRole("admin"); // boolean
auth.hasAnyRole("admin", "moderator"); // boolean
RequireAuth Component¶
interface RequireAuthProps {
roles?: Role | Role[];
fallback?: JSX.Element;
children: JSX.Element;
}
// Usage in router
<RequireAuth roles={["user", "moderator", "admin"]}>
<QuestionsPage />
</RequireAuth>;
Behavior:
- Loading — renders nothing (waits for auth initialization)
- Not authenticated — auto-redirects to Keycloak login via
auth.signIn() - Wrong role — renders
<AccessDenied variant="forbidden" />
Role Extraction¶
Roles are extracted from the JWT access token payload (decoded client-side), not from oidc-client-ts profile claims:
function extractRoles(accessToken: string | undefined): string[] {
// Decodes JWT base64 payload
// Extracts realm_access.roles + resource_access.*.roles
// Returns deduplicated array
}
// Exposed as reactive accessors via useAuth()
const roles = createMemo(() => extractRoles(accessToken()));
const isAdmin = createMemo(() =>
roles().some((r) => r.toLowerCase() === "admin"),
);
Authenticated API Calls¶
Uses openapi-fetch middleware instead of manual fetch — token is injected automatically on every request:
// src/api/client.ts
const authMiddleware: Middleware = {
async onRequest({ request }) {
const token = await getAccessToken();
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
return request;
},
};
export const organizerApi = createClient<OrganizerPaths>({
baseUrl: `${baseUrl}/organizer`,
});
organizerApi.use(authMiddleware);
The getAccessToken() function handles silent token renewal when the current token is expired.
Auth Button (Navbar)¶
<Show
when={auth.isAuthenticated()}
fallback={<button onClick={() => auth.signIn()}>Login</button>}
>
<button onClick={() => auth.signOut()}>
Logout ({auth.user()?.profile?.preferred_username})
</button>
</Show>
Token Storage¶
| Storage | Use Case | Security |
|---|---|---|
| sessionStorage (oidc-client-ts) | Access + Refresh tokens | Cleared on tab close |
| localStorage | Never use | Vulnerable to XSS |
Security Best Practices¶
- Never store tokens in localStorage - use sessionStorage
- Use PKCE for public clients (SPA)
- Short token lifetimes - 5 min access, 30 min refresh
- Refresh token rotation enabled in Keycloak
- HTTPS everywhere in production
- CORS properly configured - only allow known origins
- Validate JWT signature on every request
- Check token expiration before API calls
Exception Handling¶
| Scenario | HTTP Status | Exception |
|---|---|---|
| No token provided | 401 Unauthorized | - |
| Invalid/expired token | 401 Unauthorized | - |
| Valid token, wrong role | 403 Forbidden | UnauthorizedAccessException |
| Token valid, user not found | 403 Forbidden | UnauthorizedAccessException |