Skip to content

Commit 8dfcca5

Browse files
feat(auth): generic external SSO / OIDC login + refresh-token sessions + docs (#328)
* feat(auth): add generic external SSO / OIDC login provider (#327) Delegate PoracleWeb login to any external OAuth2/OpenID Connect provider, alongside the built-in Discord and Telegram methods. Enables single sign-on (e.g. pointing PoracleWeb at the PogoAlerts OAuth2 server) while staying provider-agnostic so any self-hoster can configure their own IdP. Implemented as a configurable twin of the Discord flow: - OidcSettings (URLs, client id/secret, scopes, claim mapping, PKCE flag) - GET /api/auth/oidc/login + /callback: auth-code exchange with PKCE, CSRF state in HttpOnly cookies; reads a configurable identity claim (default discord_id, falls back to sub), looks it up in the human table, reuses GetRolesAsync + guild-role gating, mints the existing internal JWT - providers endpoint gains an oidc block; enable_oidc site-setting runtime toggle (admins can always log in to re-enable); OIDC_* env bridge with auto-infer; documented in .env.example - Frontend: oidc provider model, loginWithOidc(), /auth/oidc/callback route, login button + disabled hint + error codes, admin External SSO group, English i18n keys Tests: backend providers oidc block + /oidc/login redirect/PKCE (1394 pass); frontend OIDC button visibility + click delegation (48 pass). * feat(auth): OIDC refresh-token sessions, SSO refinements, and docs Optional, provider-agnostic consumption of the external SSO provider's refresh token for silent session renewal + revocation propagation (default OFF via OIDC_USE_REFRESH_TOKENS), plus surrounding SSO login refinements and a comprehensive OIDC documentation set. Fully OIDC-provider-agnostic; PogoAlerts is only the reference provider. Backend: - oidc_sessions table (EF migration AddOidcSessions): the provider refresh token is stored server-side, DataProtection-encrypted, never sent to the browser; the browser holds only an opaque rotating token keyed to a rotation family. - POST /api/auth/oidc/refresh (rotate + live user re-validation + family-revoke on replay/provider-revoke) and /oidc/refresh/revoke; OidcSessionCleanupService reaps expired/stale rows (OIDC_SESSION_REVOKED_RETENTION_DAYS, default 2). - Provider-agnostic IOidcClient shared by login callback and refresh: configurable client_secret_post|client_secret_basic, optional/non-rotating refresh tokens, offline_access auto-appended; no discovery/JWKS/id_token dependency. - Per-login short JWT for refresh-backed OIDC sessions (OIDC_ACCESS_TOKEN_MINUTES, default 30); Discord/Telegram/local logins keep the 24h JWT. - Refresh is env-controlled only (no runtime admin toggle — coupled to JWT lifetime). Frontend: - TokenStoreService (single-flight refresh) + oidcRefreshInterceptor (proactive pre-expiry + reactive 401-retry, null-refresh-token guard so non-refresh logins keep the existing 401->logout path); callback stores the opaque token; logout revokes the session server-side. - SSO login refinements: Local/SSO auth-mode switch, single-logout toggle, auto-redirect + signed-out panel, i18n. Docs (MkDocs / gh-pages): - New "External SSO (OIDC)" setup page + reworked "OIDC Refresh Tokens" page (Mermaid flows, security model, provider matrix: Keycloak/Authentik/Auth0/ Google/Azure-Entra/Okta/PogoAlerts). - OIDC env vars added to the Configuration Reference, enable_oidc/enable_oidc_slo to Site Settings, an OIDC troubleshooting section, and nav entries. Tests: xUnit (session rotation/replay/cap/cleanup over SQLite, provider-agnostic client, providers/callback) + Jest (token-store single-flight, interceptor proactive/reactive/loop-guard). Backend 1422 pass, frontend 872 pass.
1 parent 0310c56 commit 8dfcca5

60 files changed

Lines changed: 5048 additions & 346 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ TELEGRAM_ENABLED=false
7575
# TELEGRAM_BOT_TOKEN=
7676
# TELEGRAM_BOT_USERNAME=
7777

78+
# ═══════════════════════════════════════════════════════════════════════════════
79+
# EXTERNAL SSO / OIDC (optional — delegate login to your own OAuth2/OIDC provider)
80+
# ═══════════════════════════════════════════════════════════════════════════════
81+
# Point PoracleWeb at any OAuth2/OIDC provider (e.g. PogoAlerts) for single sign-on.
82+
# The provider's userinfo endpoint must return a claim holding the user's Poracle id
83+
# (a Discord/Telegram id) — set OIDC_IDENTITY_CLAIM to that claim name.
84+
# Enabled is auto-inferred when ClientId + the three URLs are all set; set explicitly to override.
85+
# OIDC_ENABLED=true
86+
# OIDC_PROVIDER_NAME=PogoAlerts
87+
# OIDC_AUTHORIZATION_URL=https://pogoalerts.net/login
88+
# OIDC_TOKEN_URL=https://pogoalerts.net/api/oauth/token
89+
# OIDC_USERINFO_URL=https://pogoalerts.net/api/oauth/userinfo
90+
# OIDC_CLIENT_ID=your_oidc_client_id
91+
# OIDC_CLIENT_SECRET=your_oidc_client_secret
92+
# OIDC_SCOPES=openid profile email
93+
# OIDC_IDENTITY_CLAIM=discord_id
94+
# OIDC_USERNAME_CLAIM=preferred_username
95+
# OIDC_AVATAR_CLAIM=picture
96+
# OIDC_IDENTITY_TYPE=discord:user
97+
# OIDC_USE_PKCE=true
98+
#
99+
# --- Refresh tokens (optional, opt-in) — silent session renewal + revocation propagation ---
100+
# When OFF (default) the provider's tokens are discarded after login and the internal session
101+
# JWT lives its full Jwt:ExpirationMinutes (24h); users re-auth at expiry. When ON, PoracleWeb
102+
# brokers the provider's refresh token SERVER-SIDE (encrypted at rest, never sent to the browser),
103+
# silently renews the session, and propagates provider-side disable/logout. Requires the provider
104+
# to actually issue a refresh token. Fully provider-agnostic — see docs/configuration/oidc-refresh-tokens.md.
105+
# OIDC_USE_REFRESH_TOKENS=true
106+
# OIDC_ACCESS_TOKEN_MINUTES=30 # internal JWT lifetime for refresh-backed OIDC sessions only
107+
# OIDC_REFRESH_TOKEN_LIFETIME_DAYS=30 # PoracleWeb-side absolute session cap before a real re-login
108+
# OIDC_SESSION_REVOKED_RETENTION_DAYS=2 # how long revoked/rotated session rows are kept (replay detection) before cleanup deletes them
109+
# OIDC_OFFLINE_ACCESS_SCOPE=offline_access # appended to the authorize scope so the provider issues an RT; empty to disable
110+
# OIDC_TOKEN_AUTH_METHOD=client_secret_post # client_secret_post (body) | client_secret_basic (HTTP Basic)
111+
#
112+
# Per-provider notes (token auth method / offline scope / identity claim):
113+
# PogoAlerts : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=discord_id
114+
# Keycloak : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub
115+
# Authentik : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub
116+
# Auth0 : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub
117+
# Okta : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub
118+
# Azure/Entra: OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub
119+
# Google : OIDC_OFFLINE_ACCESS_SCOPE= (empty) and append ?access_type=offline to OIDC_AUTHORIZATION_URL
120+
78121
# ═══════════════════════════════════════════════════════════════════════════════
79122
# PORACLE API — your running PoracleNG instance
80123
# ═══════════════════════════════════════════════════════════════════════════════

Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ public interface IJwtService
1515
/// </summary>
1616
string GenerateToken(UserInfo user);
1717

18+
/// <summary>
19+
/// Generates a fresh JWT with an explicit lifetime (minutes), overriding the configured
20+
/// default. Used for refresh-backed OIDC sessions, which are deliberately short-lived so
21+
/// provider-side revocation propagates quickly via silent refresh.
22+
/// </summary>
23+
string GenerateToken(UserInfo user, int lifetimeMinutes);
24+
1825
/// <summary>
1926
/// Generates a JWT for an impersonated user. Includes an <c>impersonatedBy</c> claim
2027
/// identifying the admin who initiated the impersonation.

Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ public string GenerateToken(UserInfo user)
3333
return this.WriteToken(claims);
3434
}
3535

36+
public string GenerateToken(UserInfo user, int lifetimeMinutes)
37+
{
38+
var claims = BuildClaims(user);
39+
return this.WriteToken(claims, lifetimeMinutes);
40+
}
41+
3642
public string GenerateImpersonationToken(UserInfo user, string impersonatedBy)
3743
{
3844
var claims = BuildClaims(user);
@@ -88,7 +94,9 @@ private static List<Claim> BuildClaims(UserInfo user)
8894
return claims;
8995
}
9096

91-
private string WriteToken(List<Claim> claims)
97+
private string WriteToken(List<Claim> claims) => this.WriteToken(claims, this._settings.ExpirationMinutes);
98+
99+
private string WriteToken(List<Claim> claims, int lifetimeMinutes)
92100
{
93101
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this._settings.Secret));
94102
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -97,7 +105,7 @@ private string WriteToken(List<Claim> claims)
97105
issuer: this._settings.Issuer,
98106
audience: this._settings.Audience,
99107
claims: claims,
100-
expires: DateTime.UtcNow.AddMinutes(this._settings.ExpirationMinutes),
108+
expires: DateTime.UtcNow.AddMinutes(lifetimeMinutes),
101109
signingCredentials: credentials);
102110

103111
return new JwtSecurityTokenHandler().WriteToken(token);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
namespace Pgan.PoracleWebNet.Api.Configuration;
2+
3+
/// <summary>
4+
/// Configuration for a generic external OIDC / OAuth2 login provider. This lets any
5+
/// self-hoster delegate PoracleWeb login to their own identity provider (PGAN's
6+
/// PogoAlerts being one instance). It mirrors the Discord flow, parameterized by config.
7+
/// All values come from env/appsettings (the provider secret is never stored in the DB);
8+
/// the admin runtime on/off toggle is the separate <c>enable_oidc</c> site setting.
9+
/// </summary>
10+
public class OidcSettings
11+
{
12+
/// <summary>Master switch from server config. When false the provider is hidden regardless of other values.</summary>
13+
public bool Enabled { get; set; }
14+
15+
/// <summary>Display name shown on the login button, e.g. "PogoAlerts".</summary>
16+
public string ProviderName { get; set; } = string.Empty;
17+
18+
/// <summary>Browser-facing authorization endpoint. For PogoAlerts this is e.g. https://pogoalerts.net/login.</summary>
19+
public string AuthorizationUrl { get; set; } = string.Empty;
20+
21+
/// <summary>Token endpoint that exchanges the authorization code for an access token.</summary>
22+
public string TokenUrl { get; set; } = string.Empty;
23+
24+
/// <summary>
25+
/// Optional OIDC RP-initiated logout (end-session) endpoint. When set, signing out of
26+
/// PoracleWeb redirects the browser here with a <c>post_logout_redirect_uri</c> so the
27+
/// provider can also end its own session (true single logout). When empty, logout is
28+
/// local-only (the provider session survives). For PogoAlerts this is e.g.
29+
/// https://pogoalerts.net/logout.
30+
/// </summary>
31+
public string EndSessionUrl { get; set; } = string.Empty;
32+
33+
/// <summary>UserInfo endpoint (OpenID Connect compatible) returning the user's claims.</summary>
34+
public string UserInfoUrl { get; set; } = string.Empty;
35+
36+
public string ClientId { get; set; } = string.Empty;
37+
public string ClientSecret { get; set; } = string.Empty;
38+
39+
/// <summary>Space-delimited OAuth scopes requested at authorization time.</summary>
40+
public string Scopes { get; set; } = "openid profile email";
41+
42+
/// <summary>
43+
/// UserInfo claim whose value is the Poracle <c>human</c> id (a Discord or Telegram id).
44+
/// Defaults to <c>discord_id</c> (PogoAlerts passes through the linked Discord id);
45+
/// falls back to <c>sub</c> when the configured claim is absent.
46+
/// </summary>
47+
public string IdentityClaim { get; set; } = "discord_id";
48+
49+
/// <summary>UserInfo claim used as the display username.</summary>
50+
public string UsernameClaim { get; set; } = "preferred_username";
51+
52+
/// <summary>UserInfo claim used as the avatar URL.</summary>
53+
public string AvatarClaim { get; set; } = "picture";
54+
55+
/// <summary>
56+
/// Value written to the JWT <c>type</c> claim for users who log in via this provider.
57+
/// Defaults to <c>discord:user</c> so downstream admin/role resolution treats the
58+
/// passed-through Discord id consistently with a direct Discord login.
59+
/// </summary>
60+
public string IdentityType { get; set; } = "discord:user";
61+
62+
/// <summary>Whether to use PKCE (Proof Key for Code Exchange) — recommended and supported by PogoAlerts.</summary>
63+
public bool UsePkce { get; set; } = true;
64+
65+
/// <summary>
66+
/// Master opt-in for consuming the provider's refresh token (silent session renewal +
67+
/// revocation propagation). Default <c>false</c> — when off, behavior is identical to a
68+
/// plain login: the provider's tokens are discarded and the internal JWT lives its full
69+
/// <see cref="JwtSettings.ExpirationMinutes"/>. Requires the provider to actually issue a
70+
/// refresh token (standard providers gate that behind the <c>offline_access</c> scope —
71+
/// see <see cref="OfflineAccessScope"/>).
72+
/// </summary>
73+
public bool UseRefreshTokens { get; set; }
74+
75+
/// <summary>
76+
/// Internal JWT lifetime (minutes) for refresh-backed OIDC sessions only. Kept short so a
77+
/// disable/revocation at the provider propagates within roughly one access-token lifetime.
78+
/// Other logins (Discord, Telegram, local, OIDC without refresh) are unaffected and keep
79+
/// <see cref="JwtSettings.ExpirationMinutes"/>.
80+
/// </summary>
81+
public int AccessTokenMinutes { get; set; } = 30;
82+
83+
/// <summary>
84+
/// PoracleWeb-side absolute cap (days) on a refresh session/family before a real re-login is
85+
/// forced. Independent of the provider's own refresh-token lifetime; if the provider's token
86+
/// expires first, the refresh call fails and the session is revoked — correct either way.
87+
/// </summary>
88+
public int RefreshTokenLifetimeDays { get; set; } = 30;
89+
90+
/// <summary>
91+
/// How long (days) a revoked/rotated <c>oidc_sessions</c> row is retained before the cleanup
92+
/// service deletes it. Revoked rows are kept briefly so a replayed old opaque token is still
93+
/// detected (and family-revoked) rather than silently 401ing; replay happens fast, so a short
94+
/// window suffices. Kept separate from <see cref="RefreshTokenLifetimeDays"/> so frequent
95+
/// rotation doesn't pile up 30 days of dead rows. Expired rows are deleted regardless of this.
96+
/// </summary>
97+
public int RevokedRetentionDays { get; set; } = 2;
98+
99+
/// <summary>
100+
/// Scope appended to the authorization request (only when <see cref="UseRefreshTokens"/> is on
101+
/// and it isn't already present) so a standards-compliant provider issues a refresh token.
102+
/// Defaults to <c>offline_access</c>. Set empty for providers that issue refresh tokens
103+
/// unconditionally, or that use a non-standard mechanism (e.g. Google's
104+
/// <c>access_type=offline</c> appended directly to <see cref="AuthorizationUrl"/>).
105+
/// </summary>
106+
public string OfflineAccessScope { get; set; } = "offline_access";
107+
108+
/// <summary>
109+
/// How client credentials are presented at the token endpoint: <c>client_secret_post</c>
110+
/// (default — credentials in the form body, what PogoAlerts uses) or <c>client_secret_basic</c>
111+
/// (HTTP Basic auth header — the default for Keycloak/Okta). Applies to both the
112+
/// authorization-code exchange and the refresh-token grant.
113+
/// </summary>
114+
public string TokenEndpointAuthMethod { get; set; } = "client_secret_post";
115+
}

Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv
5959
services.AddScoped<IQuickPickDefinitionRepository, QuickPickDefinitionRepository>();
6060
services.AddScoped<IQuickPickAppliedStateRepository, QuickPickAppliedStateRepository>();
6161
services.AddScoped<IUserAreaDualWriter, UserAreaDualWriter>();
62+
services.AddScoped<IOidcSessionRepository, OidcSessionRepository>();
6263

6364
// Register Services
6465
services.AddScoped<IMonsterService, MonsterService>();
@@ -149,13 +150,19 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv
149150
}
150151
});
151152

153+
// Register the generic OIDC HTTP client (code exchange / refresh / userinfo) and the
154+
// server-side refresh-session service (opaque-token rotation + encrypted RT storage).
155+
services.AddHttpClient<Services.Oidc.IOidcClient, Services.Oidc.OidcClient>();
156+
services.AddScoped<Services.Oidc.IOidcSessionService, Services.Oidc.OidcSessionService>();
157+
152158
// Register JWT service (shared token generation across controllers)
153159
services.AddSingleton<IJwtService, JwtService>();
154160

155161
// Register settings
156162
services.Configure<JwtSettings>(configuration.GetSection("Jwt"));
157163
services.Configure<DiscordSettings>(configuration.GetSection("Discord"));
158164
services.Configure<TelegramSettings>(configuration.GetSection("Telegram"));
165+
services.Configure<OidcSettings>(configuration.GetSection("Oidc"));
159166
services.Configure<PoracleSettings>(configuration.GetSection("Poracle"));
160167
services.Configure<KojiSettings>(configuration.GetSection("Koji"));
161168

0 commit comments

Comments
 (0)