Skip to content

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:

{
  "Parameters": {
    "keycloak-authority": "https://auth.bluebraces.online"
  }
}

Note: You must run Keycloak separately from the Keycloak repo 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 UserManager events
  • 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

  1. Never store tokens in localStorage - use sessionStorage
  2. Use PKCE for public clients (SPA)
  3. Short token lifetimes - 5 min access, 30 min refresh
  4. Refresh token rotation enabled in Keycloak
  5. HTTPS everywhere in production
  6. CORS properly configured - only allow known origins
  7. Validate JWT signature on every request
  8. 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