diff --git a/docs/REST_API.md b/docs/REST_API.md index 6ce1e569f..9da619eb3 100644 --- a/docs/REST_API.md +++ b/docs/REST_API.md @@ -77,6 +77,68 @@ X-Api-Key: your-api-key-here > **Deprecation notice:** `X-Api-Key` will be removed in a future version. Migrate to bearer tokens for new integrations. +### OAuth2 / OIDC Single Sign-On (Authentik and other providers) + +Armada can delegate **dashboard login** to a generic OAuth2 / OIDC provider (for +example [Authentik](https://goauthentik.io/)) using the Authorization-Code flow +with PKCE. On a successful SSO login the server maps the provider identity to an +Armada user and mints the same encrypted session token described above, so the +rest of the API is unchanged. + +The flow is redirect-based and requires no client secret in the browser: + +1. The dashboard calls `GET /api/v1/auth/oauth/config` and shows a + "Sign in with <DisplayName>" button when SSO is enabled. +2. The button navigates to `GET /api/v1/auth/oauth/authorize`, which redirects + to the provider's authorization endpoint. +3. After the user authenticates, the provider redirects back to + `GET /api/v1/auth/oauth/callback`. Armada exchanges the code for tokens, + reads the userinfo endpoint, resolves or provisions the user, and redirects + to `/dashboard#oauth_token=`. The dashboard consumes the + token from the URL fragment and clears it from history. + +Users are matched to an existing Armada user by email within +`OAuth2.DefaultTenantId`. When `OAuth2.AllowAutoProvision` is `true`, a new +non-admin user is created on first login; when `false`, only users an admin has +already created may sign in. SSO users are provisioned with a random, +unusable password (password login is effectively disabled for them). + +**Settings** (`OAuth2` block in `settings.json`): + +| Field | Description | +|-------|-------------| +| `Enabled` | Master switch for SSO. | +| `DisplayName` | Label on the dashboard sign-in button (e.g. `Authentik`). | +| `AuthorizationEndpoint` | Provider authorize URL (Authentik: `.../application/o/authorize/`). | +| `TokenEndpoint` | Provider token URL (Authentik: `.../application/o/token/`). | +| `UserInfoEndpoint` | Provider userinfo URL (Authentik: `.../application/o/userinfo/`). | +| `ClientId` / `ClientSecret` | OAuth2 client credentials issued by the provider. | +| `Scopes` | Requested scopes (default `openid profile email`). | +| `RedirectUri` | Optional explicit callback URI; derived from the request when empty. Set this behind a reverse proxy. | +| `UsePkce` | Use PKCE S256 (default `true`). | +| `EmailClaim` / `NameClaim` | Userinfo claims used for identity/display name (default `email` / `name`). | +| `RequireVerifiedEmail` | Require the provider's `email_verified` claim to be `true` before trusting the email (default `true`). Prevents takeover via an unverified email. | +| `AllowAutoProvision` | Auto-create users on first login (default `true`). | +| `DefaultTenantId` | Tenant SSO users are mapped/provisioned into (default `default`). | + +Example (Authentik): + +```json +"OAuth2": { + "Enabled": true, + "DisplayName": "Authentik", + "AuthorizationEndpoint": "https://authentik.example.com/application/o/authorize/", + "TokenEndpoint": "https://authentik.example.com/application/o/token/", + "UserInfoEndpoint": "https://authentik.example.com/application/o/userinfo/", + "ClientId": "armada", + "ClientSecret": "", + "RedirectUri": "https://armada.example.com/api/v1/auth/oauth/callback" +} +``` + +Register `https:///api/v1/auth/oauth/callback` as the redirect URI +in the provider's application configuration. + ### Authorization Tiers All endpoints fall into one of three authorization levels: @@ -104,6 +166,9 @@ Operational entities persist both `TenantId` and `UserId`. Those ownership colum | `/api/v1/authenticate` | POST | NoAuthRequired | | | `/api/v1/tenants/lookup` | POST | NoAuthRequired | Input: email, returns matching tenants | | `/api/v1/onboarding` | POST | NoAuthRequired | Gated by `AllowSelfRegistration` setting | +| `/api/v1/auth/oauth/config` | GET | NoAuthRequired | SSO public config (enabled + display name) | +| `/api/v1/auth/oauth/authorize` | GET | NoAuthRequired | Begins SSO; redirects to the provider | +| `/api/v1/auth/oauth/callback` | GET | NoAuthRequired | SSO callback; redirects to the dashboard with a session token | | `/api/v1/whoami` | GET | Authenticated | | | `/api/v1/status` | GET | Authenticated | Tenant-scoped | | `/api/v1/settings` | GET | AdminOnly | Server configuration and remote-control settings | @@ -140,6 +205,7 @@ Operational entities persist both `TenantId` and `UserId`. Those ownership colum - `POST /api/v1/authenticate` - `POST /api/v1/tenants/lookup` - `POST /api/v1/onboarding` (when `AllowSelfRegistration` is enabled) +- `GET /api/v1/auth/oauth/config`, `GET /api/v1/auth/oauth/authorize`, `GET /api/v1/auth/oauth/callback` (OAuth2 SSO) - `POST /api/v1/server/stop` (when `RequireAuthForShutdown` is `false`, the default) - `GET /dashboard` and all `/dashboard/*` paths - `GET /` (redirects to `/dashboard`) diff --git a/src/Armada.Core/Authorization/AuthorizationConfig.cs b/src/Armada.Core/Authorization/AuthorizationConfig.cs index fd00d5cb1..9f1bae51f 100644 --- a/src/Armada.Core/Authorization/AuthorizationConfig.cs +++ b/src/Armada.Core/Authorization/AuthorizationConfig.cs @@ -51,6 +51,7 @@ public static PermissionLevel GetPermissionLevel(string method, string path) if (path.EndsWith("/authenticate") && method == "POST") return PermissionLevel.NoAuthRequired; if (path.EndsWith("/tenants/lookup") && method == "POST") return PermissionLevel.NoAuthRequired; if (path.EndsWith("/onboarding") && method == "POST") return PermissionLevel.NoAuthRequired; + if (path.StartsWith("/api/v1/auth/oauth")) return PermissionLevel.NoAuthRequired; if (path.StartsWith("/dashboard")) return PermissionLevel.NoAuthRequired; if (path == "/") return PermissionLevel.NoAuthRequired; diff --git a/src/Armada.Core/Models/OAuthConfigResult.cs b/src/Armada.Core/Models/OAuthConfigResult.cs new file mode 100644 index 000000000..df68a1c25 --- /dev/null +++ b/src/Armada.Core/Models/OAuthConfigResult.cs @@ -0,0 +1,23 @@ +namespace Armada.Core.Models +{ + /// + /// Public OAuth2 configuration surfaced to the dashboard so it can show or + /// hide the single sign-on button. Contains no secrets. + /// + public class OAuthConfigResult + { + #region Public-Members + + /// + /// Whether OAuth2 single sign-on is enabled and fully configured. + /// + public bool Enabled { get; set; } = false; + + /// + /// Label to show on the sign-in button. + /// + public string DisplayName { get; set; } = "Single Sign-On"; + + #endregion + } +} diff --git a/src/Armada.Core/Models/OAuthFlowState.cs b/src/Armada.Core/Models/OAuthFlowState.cs new file mode 100644 index 000000000..6810b6657 --- /dev/null +++ b/src/Armada.Core/Models/OAuthFlowState.cs @@ -0,0 +1,26 @@ +namespace Armada.Core.Models +{ + using System; + + /// + /// Short-lived server-side state for an in-flight OAuth2 Authorization-Code + /// login. Correlates the provider redirect with the original request and + /// carries the PKCE code verifier. Single-use. + /// + public class OAuthFlowState + { + #region Public-Members + + /// + /// PKCE code verifier generated when the flow started. + /// + public string CodeVerifier { get; set; } = string.Empty; + + /// + /// UTC time after which this flow state is no longer valid. + /// + public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow; + + #endregion + } +} diff --git a/src/Armada.Core/Models/OAuthLoginResult.cs b/src/Armada.Core/Models/OAuthLoginResult.cs new file mode 100644 index 000000000..4b32c084b --- /dev/null +++ b/src/Armada.Core/Models/OAuthLoginResult.cs @@ -0,0 +1,45 @@ +namespace Armada.Core.Models +{ + using System; + + /// + /// Result of completing an OAuth2 login (code exchange + user resolution + + /// session token minting). + /// + public class OAuthLoginResult + { + #region Public-Members + + /// + /// Whether the login succeeded. + /// + public bool Success { get; set; } = false; + + /// + /// Minted Armada session token (X-Token) when successful. + /// + public string? Token { get; set; } = null; + + /// + /// Session token expiry when successful. + /// + public DateTime? ExpiresUtc { get; set; } = null; + + /// + /// Short, safe error reason when unsuccessful (surfaced to the dashboard). + /// + public string? ErrorMessage { get; set; } = null; + + /// + /// Create a failed result. + /// + /// Error reason. + /// Failed result. + public static OAuthLoginResult Failed(string error) + { + return new OAuthLoginResult { Success = false, ErrorMessage = error }; + } + + #endregion + } +} diff --git a/src/Armada.Core/Models/OAuthTokenResponse.cs b/src/Armada.Core/Models/OAuthTokenResponse.cs new file mode 100644 index 000000000..173a6a096 --- /dev/null +++ b/src/Armada.Core/Models/OAuthTokenResponse.cs @@ -0,0 +1,56 @@ +namespace Armada.Core.Models +{ + using System.Text.Json.Serialization; + + /// + /// Strongly-typed OAuth2 token endpoint response. + /// + public class OAuthTokenResponse + { + #region Public-Members + + /// + /// Access token used to call the userinfo endpoint. + /// + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } = null; + + /// + /// Token type (typically "Bearer"). + /// + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } = null; + + /// + /// OIDC ID token, when the "openid" scope is requested. + /// + [JsonPropertyName("id_token")] + public string? IdToken { get; set; } = null; + + /// + /// Lifetime of the access token in seconds. + /// + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } = null; + + /// + /// Granted scopes. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } = null; + + /// + /// Error code returned by the provider when the exchange fails. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } = null; + + /// + /// Human-readable error description returned by the provider. + /// + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } = null; + + #endregion + } +} diff --git a/src/Armada.Core/Models/OAuthUserInfo.cs b/src/Armada.Core/Models/OAuthUserInfo.cs new file mode 100644 index 000000000..9b06641e3 --- /dev/null +++ b/src/Armada.Core/Models/OAuthUserInfo.cs @@ -0,0 +1,82 @@ +namespace Armada.Core.Models +{ + using System.Text.Json.Serialization; + + /// + /// Strongly-typed OIDC userinfo response covering the standard claims + /// Authentik and other generic OAuth2/OIDC providers return. + /// + public class OAuthUserInfo + { + #region Public-Members + + /// + /// Subject identifier (stable, provider-unique user id). + /// + [JsonPropertyName("sub")] + public string? Sub { get; set; } = null; + + /// + /// Email address claim. + /// + [JsonPropertyName("email")] + public string? Email { get; set; } = null; + + /// + /// Whether the provider has verified ownership of the email (OIDC email_verified claim). + /// + [JsonPropertyName("email_verified")] + public bool? EmailVerified { get; set; } = null; + + /// + /// Preferred username claim. + /// + [JsonPropertyName("preferred_username")] + public string? PreferredUsername { get; set; } = null; + + /// + /// Full display name claim. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } = null; + + /// + /// Given (first) name claim. + /// + [JsonPropertyName("given_name")] + public string? GivenName { get; set; } = null; + + /// + /// Family (last) name claim. + /// + [JsonPropertyName("family_name")] + public string? FamilyName { get; set; } = null; + + #endregion + + #region Public-Methods + + /// + /// Resolve a configured claim name to its value using the standard claims. + /// + /// Claim name from settings (e.g. "email", "preferred_username", "sub", "name"). + /// Claim value, or null if not present / not a recognized claim. + public string? GetClaim(string claimName) + { + if (string.IsNullOrWhiteSpace(claimName)) return null; + + switch (claimName.ToLowerInvariant()) + { + case "sub": return Sub; + case "email": return Email; + case "preferred_username": return PreferredUsername; + case "name": return Name; + case "given_name": return GivenName; + case "family_name": return FamilyName; + default: return null; + } + } + + #endregion + } +} diff --git a/src/Armada.Core/Services/Interfaces/IOAuth2Service.cs b/src/Armada.Core/Services/Interfaces/IOAuth2Service.cs new file mode 100644 index 000000000..98678fd48 --- /dev/null +++ b/src/Armada.Core/Services/Interfaces/IOAuth2Service.cs @@ -0,0 +1,54 @@ +namespace Armada.Core.Services.Interfaces +{ + using System.Threading; + using System.Threading.Tasks; + using Armada.Core.Models; + + /// + /// Service for generic OAuth2 / OIDC single sign-on (Authorization-Code flow + /// with PKCE). Exchanges provider codes for identity and mints an Armada + /// session token. + /// + public interface IOAuth2Service + { + /// + /// Whether OAuth2 single sign-on is enabled and fully configured. + /// + bool IsEnabled { get; } + + /// + /// Public, secret-free configuration for the dashboard. + /// + /// OAuth config result. + OAuthConfigResult GetPublicConfig(); + + /// + /// Begin an Authorization-Code login: generate state + PKCE, store the + /// flow, and return the full provider authorization URL to redirect to. + /// + /// Callback URI the provider will redirect back to. + /// Provider authorization URL. + string BuildAuthorizationUrl(string redirectUri); + + /// + /// Complete the login: validate state, exchange the code, fetch userinfo, + /// resolve or provision the user, and mint a session token. + /// + /// Authorization code from the provider. + /// Opaque state value from the provider redirect. + /// The same callback URI used to start the flow. + /// Cancellation token. + /// Login result. + Task CompleteLoginAsync(string? code, string? state, string redirectUri, CancellationToken token = default); + + /// + /// Resolve an existing user by email in the configured tenant, or + /// provision a new one when auto-provisioning is enabled. + /// + /// Email / identity from the provider. + /// Optional display name from the provider. + /// Cancellation token. + /// Resolved user, or null when the user cannot be signed in. + Task ResolveOrProvisionUserAsync(string email, string? displayName, CancellationToken token = default); + } +} diff --git a/src/Armada.Core/Services/OAuth2Service.cs b/src/Armada.Core/Services/OAuth2Service.cs new file mode 100644 index 000000000..d4c7faf87 --- /dev/null +++ b/src/Armada.Core/Services/OAuth2Service.cs @@ -0,0 +1,289 @@ +namespace Armada.Core.Services +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using SyslogLogging; + using Armada.Core.Database; + using Armada.Core.Models; + using Armada.Core.Services.Interfaces; + using Armada.Core.Settings; + + /// + /// Generic OAuth2 / OIDC single sign-on service. Implements the + /// Authorization-Code flow (with PKCE) by hand because Armada's REST layer + /// is Watson, not ASP.NET Core. On success it mints a normal Armada session + /// token so the rest of the auth stack is unchanged. + /// + public class OAuth2Service : IOAuth2Service + { + #region Public-Members + + /// + public bool IsEnabled => _Settings.OAuth2.IsConfigured(); + + #endregion + + #region Private-Members + + private readonly string _Header = "[OAuth2Service] "; + private readonly DatabaseDriver _Database; + private readonly ISessionTokenService _SessionTokenService; + private readonly ArmadaSettings _Settings; + private readonly LoggingModule _Logging; + private readonly OAuthStateStore _StateStore; + private readonly HttpClient _HttpClient; + + #endregion + + #region Constructors-and-Factories + + /// + /// Instantiate. + /// + /// Database driver. + /// Session token service. + /// Application settings. + /// Logging module. + /// Optional HTTP client (a shared instance is created when null). + /// Optional state store (a default is created when null). + public OAuth2Service( + DatabaseDriver database, + ISessionTokenService sessionTokenService, + ArmadaSettings settings, + LoggingModule logging, + HttpClient? httpClient = null, + OAuthStateStore? stateStore = null) + { + _Database = database ?? throw new ArgumentNullException(nameof(database)); + _SessionTokenService = sessionTokenService ?? throw new ArgumentNullException(nameof(sessionTokenService)); + _Settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _Logging = logging ?? throw new ArgumentNullException(nameof(logging)); + _HttpClient = httpClient ?? new HttpClient(); + _StateStore = stateStore ?? new OAuthStateStore(); + } + + #endregion + + #region Public-Methods + + /// + public OAuthConfigResult GetPublicConfig() + { + return new OAuthConfigResult + { + Enabled = IsEnabled, + DisplayName = _Settings.OAuth2.DisplayName + }; + } + + /// + public string BuildAuthorizationUrl(string redirectUri) + { + if (string.IsNullOrEmpty(redirectUri)) throw new ArgumentNullException(nameof(redirectUri)); + if (!IsEnabled) throw new InvalidOperationException("OAuth2 is not configured"); + + OAuth2Settings cfg = _Settings.OAuth2; + + string codeVerifier = cfg.UsePkce ? PkceHelper.GenerateCodeVerifier() : string.Empty; + string state = _StateStore.Issue(codeVerifier); + + Dictionary query = new Dictionary + { + ["response_type"] = "code", + ["client_id"] = cfg.ClientId!, + ["redirect_uri"] = redirectUri, + ["scope"] = cfg.Scopes, + ["state"] = state + }; + + if (cfg.UsePkce) + { + query["code_challenge"] = PkceHelper.ComputeCodeChallenge(codeVerifier); + query["code_challenge_method"] = "S256"; + } + + return AppendQuery(cfg.AuthorizationEndpoint!, query); + } + + /// + public async Task CompleteLoginAsync(string? code, string? state, string redirectUri, CancellationToken token = default) + { + if (!IsEnabled) return OAuthLoginResult.Failed("sso_disabled"); + if (string.IsNullOrEmpty(code)) return OAuthLoginResult.Failed("missing_code"); + + OAuthFlowState? flow = _StateStore.Consume(state); + if (flow == null) return OAuthLoginResult.Failed("invalid_state"); + + OAuth2Settings cfg = _Settings.OAuth2; + + try + { + OAuthTokenResponse? tokenResponse = await ExchangeCodeAsync(code, redirectUri, flow.CodeVerifier, cfg, token).ConfigureAwait(false); + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + { + string detail = tokenResponse?.Error ?? "no_access_token"; + _Logging.Warn(_Header + "token exchange failed: " + detail); + return OAuthLoginResult.Failed("token_exchange_failed"); + } + + OAuthUserInfo? userInfo = await FetchUserInfoAsync(tokenResponse.AccessToken, cfg, token).ConfigureAwait(false); + if (userInfo == null) return OAuthLoginResult.Failed("userinfo_failed"); + + string? email = userInfo.GetClaim(cfg.EmailClaim); + if (string.IsNullOrWhiteSpace(email)) email = userInfo.Email; + if (string.IsNullOrWhiteSpace(email)) return OAuthLoginResult.Failed("no_email_claim"); + + // Require a verified email before trusting it for identity mapping. + // Otherwise a provider account with an unverified, attacker-chosen + // email could be matched to (and take over) an existing Armada user. + if (cfg.RequireVerifiedEmail && userInfo.EmailVerified != true) + { + _Logging.Warn(_Header + "rejecting SSO login: email not verified by provider"); + return OAuthLoginResult.Failed("email_not_verified"); + } + + string? displayName = userInfo.GetClaim(cfg.NameClaim) ?? userInfo.Name; + + UserMaster? user = await ResolveOrProvisionUserAsync(email, displayName, token).ConfigureAwait(false); + if (user == null) return OAuthLoginResult.Failed("user_not_permitted"); + + AuthenticateResult session = _SessionTokenService.CreateToken(user.TenantId, user.Id); + return new OAuthLoginResult + { + Success = true, + Token = session.Token, + ExpiresUtc = session.ExpiresUtc + }; + } + catch (Exception ex) + { + _Logging.Warn(_Header + "login failed: " + ex.Message); + return OAuthLoginResult.Failed("login_error"); + } + } + + /// + public async Task ResolveOrProvisionUserAsync(string email, string? displayName, CancellationToken token = default) + { + if (string.IsNullOrWhiteSpace(email)) return null; + email = email.ToLowerInvariant(); + + OAuth2Settings cfg = _Settings.OAuth2; + string tenantId = cfg.DefaultTenantId; + + TenantMetadata? tenant = await _Database.Tenants.ReadAsync(tenantId, token).ConfigureAwait(false); + if (tenant == null || !tenant.Active) + { + _Logging.Warn(_Header + "SSO tenant '" + tenantId + "' not found or inactive"); + return null; + } + + UserMaster? existing = await _Database.Users.ReadByEmailAsync(tenantId, email, token).ConfigureAwait(false); + if (existing != null) + { + if (!existing.Active) return null; + return existing; + } + + if (!cfg.AllowAutoProvision) + { + _Logging.Warn(_Header + "SSO user '" + email + "' not found and auto-provision disabled"); + return null; + } + + UserMaster newUser = new UserMaster(tenantId, email, Guid.NewGuid().ToString("N")); + SplitDisplayName(displayName, newUser); + newUser.IsAdmin = false; + newUser.IsTenantAdmin = false; + await _Database.Users.CreateAsync(newUser, token).ConfigureAwait(false); + + Credential newCred = new Credential(tenantId, newUser.Id); + await _Database.Credentials.CreateAsync(newCred, token).ConfigureAwait(false); + + _Logging.Info(_Header + "auto-provisioned SSO user '" + email + "' in tenant '" + tenantId + "'"); + return newUser; + } + + #endregion + + #region Private-Methods + + private async Task ExchangeCodeAsync(string code, string redirectUri, string codeVerifier, OAuth2Settings cfg, CancellationToken token) + { + Dictionary form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["client_id"] = cfg.ClientId!, + ["client_secret"] = cfg.ClientSecret! + }; + if (cfg.UsePkce && !string.IsNullOrEmpty(codeVerifier)) + form["code_verifier"] = codeVerifier; + + using (FormUrlEncodedContent content = new FormUrlEncodedContent(form)) + using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, cfg.TokenEndpoint)) + { + request.Content = content; + request.Headers.Add("Accept", "application/json"); + using (HttpResponseMessage response = await _HttpClient.SendAsync(request, token).ConfigureAwait(false)) + { + string body = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) return null; + return JsonSerializer.Deserialize(body); + } + } + } + + private async Task FetchUserInfoAsync(string accessToken, OAuth2Settings cfg, CancellationToken token) + { + using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, cfg.UserInfoEndpoint)) + { + request.Headers.Add("Accept", "application/json"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + using (HttpResponseMessage response = await _HttpClient.SendAsync(request, token).ConfigureAwait(false)) + { + if (!response.IsSuccessStatusCode) return null; + string body = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) return null; + return JsonSerializer.Deserialize(body); + } + } + } + + private static void SplitDisplayName(string? displayName, UserMaster user) + { + if (string.IsNullOrWhiteSpace(displayName)) return; + string trimmed = displayName.Trim(); + int space = trimmed.IndexOf(' '); + if (space <= 0) + { + user.FirstName = trimmed; + return; + } + user.FirstName = trimmed.Substring(0, space); + user.LastName = trimmed.Substring(space + 1).Trim(); + } + + private static string AppendQuery(string baseUrl, Dictionary query) + { + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + foreach (KeyValuePair kvp in query) + { + if (sb.Length > 0) sb.Append('&'); + sb.Append(Uri.EscapeDataString(kvp.Key)); + sb.Append('='); + sb.Append(Uri.EscapeDataString(kvp.Value)); + } + + string separator = baseUrl.Contains('?') ? "&" : "?"; + return baseUrl + separator + sb.ToString(); + } + + #endregion + } +} diff --git a/src/Armada.Core/Services/OAuthStateStore.cs b/src/Armada.Core/Services/OAuthStateStore.cs new file mode 100644 index 000000000..1ae8577de --- /dev/null +++ b/src/Armada.Core/Services/OAuthStateStore.cs @@ -0,0 +1,94 @@ +namespace Armada.Core.Services +{ + using System; + using System.Collections.Concurrent; + using Armada.Core.Models; + + /// + /// In-memory, single-use store for in-flight OAuth2 login flows. Correlates + /// the opaque "state" value returned by the provider with the PKCE verifier. + /// Entries are single-use, unguessable (256-bit), expire, and are purged on + /// access, which defeats CSRF token forgery and replay. + /// + /// Known limitation: state is not bound to the initiating browser (Armada is + /// token-in-localStorage with no pre-auth cookie), so a "login CSRF" where an + /// attacker feeds their own valid code+state to a victim is not fully + /// prevented. Closing that requires a short-lived SameSite/HttpOnly state + /// cookie set at /authorize and verified at /callback -- tracked as a + /// follow-up hardening. + /// + public class OAuthStateStore + { + #region Private-Members + + private readonly ConcurrentDictionary _States = new ConcurrentDictionary(); + private readonly int _TtlSeconds; + + #endregion + + #region Constructors-and-Factories + + /// + /// Instantiate. + /// + /// Lifetime of a flow state in seconds (clamped to [30, 1800]). + public OAuthStateStore(int ttlSeconds = 300) + { + if (ttlSeconds < 30) ttlSeconds = 30; + if (ttlSeconds > 1800) ttlSeconds = 1800; + _TtlSeconds = ttlSeconds; + } + + #endregion + + #region Public-Methods + + /// + /// Issue a new flow, returning the opaque state value to send to the provider. + /// + /// PKCE code verifier. + /// Opaque state value. + public string Issue(string codeVerifier) + { + PurgeExpired(); + string state = PkceHelper.GenerateOpaqueToken(); + _States[state] = new OAuthFlowState + { + CodeVerifier = codeVerifier ?? string.Empty, + ExpiresUtc = DateTime.UtcNow.AddSeconds(_TtlSeconds) + }; + return state; + } + + /// + /// Validate and consume a state value (single-use). Returns null if the + /// state is unknown, already used, or expired. + /// + /// Opaque state value from the provider redirect. + /// Flow state, or null. + public OAuthFlowState? Consume(string? state) + { + if (string.IsNullOrEmpty(state)) return null; + if (!_States.TryRemove(state, out OAuthFlowState? flow)) return null; + if (flow == null) return null; + if (flow.ExpiresUtc <= DateTime.UtcNow) return null; + return flow; + } + + #endregion + + #region Private-Methods + + private void PurgeExpired() + { + DateTime now = DateTime.UtcNow; + foreach (System.Collections.Generic.KeyValuePair kvp in _States) + { + if (kvp.Value.ExpiresUtc <= now) + _States.TryRemove(kvp.Key, out OAuthFlowState? _); + } + } + + #endregion + } +} diff --git a/src/Armada.Core/Services/PkceHelper.cs b/src/Armada.Core/Services/PkceHelper.cs new file mode 100644 index 000000000..9c4132b88 --- /dev/null +++ b/src/Armada.Core/Services/PkceHelper.cs @@ -0,0 +1,63 @@ +namespace Armada.Core.Services +{ + using System; + using System.Security.Cryptography; + using System.Text; + + /// + /// Helper for RFC 7636 PKCE (Proof Key for Code Exchange) and opaque + /// URL-safe token generation. + /// + public static class PkceHelper + { + #region Public-Methods + + /// + /// Generate a high-entropy PKCE code verifier (base64url, 43 chars). + /// + /// Code verifier. + public static string GenerateCodeVerifier() + { + byte[] bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + /// + /// Compute the S256 PKCE code challenge for a verifier. + /// + /// Code verifier. + /// base64url-encoded SHA-256 challenge. + public static string ComputeCodeChallenge(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) throw new ArgumentNullException(nameof(codeVerifier)); + byte[] hash = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier)); + return Base64UrlEncode(hash); + } + + /// + /// Generate an opaque URL-safe random token (e.g. an OAuth "state" value). + /// + /// Random token. + public static string GenerateOpaqueToken() + { + byte[] bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + #endregion + + #region Private-Methods + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + #endregion + } +} diff --git a/src/Armada.Core/Services/RequestHistoryCaptureService.cs b/src/Armada.Core/Services/RequestHistoryCaptureService.cs index 6e959a19c..006941bc6 100644 --- a/src/Armada.Core/Services/RequestHistoryCaptureService.cs +++ b/src/Armada.Core/Services/RequestHistoryCaptureService.cs @@ -75,6 +75,11 @@ public bool ShouldCapture(string? path) if (string.IsNullOrWhiteSpace(path)) return false; if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) return false; + // Never capture the OAuth2 callback: its 302 Location redirect carries a + // live session token in the URL fragment, and header/body redaction is + // key-name based so it would otherwise be persisted verbatim. + if (path.StartsWith("/api/v1/auth/oauth/callback", StringComparison.OrdinalIgnoreCase)) return false; + foreach (string excluded in _Settings.RequestHistoryExcludeRoutes) { if (string.IsNullOrWhiteSpace(excluded)) continue; diff --git a/src/Armada.Core/Settings/ArmadaSettings.cs b/src/Armada.Core/Settings/ArmadaSettings.cs index aa50bf2db..1056c54fd 100644 --- a/src/Armada.Core/Settings/ArmadaSettings.cs +++ b/src/Armada.Core/Settings/ArmadaSettings.cs @@ -910,6 +910,15 @@ public RemoteControlSettings RemoteControl set => _RemoteControl = value ?? new RemoteControlSettings(); } + /// + /// Generic OAuth2 / OIDC single sign-on settings for the web dashboard. + /// + public OAuth2Settings OAuth2 + { + get => _OAuth2; + set => _OAuth2 = value ?? new OAuth2Settings(); + } + /// /// Optional: configuration for admiral-side event-driven orchestrator wakes via Claude Code /// Routines /fire API. Null or absent means the feature is disabled; admiral runs as today. @@ -1094,6 +1103,7 @@ public DefinitionOfDoneSettings DefinitionOfDone private LearnedFactsPruneOptions _LearnedFactsPrune = new LearnedFactsPruneOptions(); private int _FleetCurateDualJudgeFanOutWarnThreshold = 3; private RemoteControlSettings _RemoteControl = new RemoteControlSettings(); + private OAuth2Settings _OAuth2 = new OAuth2Settings(); private DatabaseSettings _Database = new DatabaseSettings(); private CodeIndexSettings _CodeIndex = new CodeIndexSettings(); private SelfDeploySettings _SelfDeploy = new SelfDeploySettings(); diff --git a/src/Armada.Core/Settings/OAuth2Settings.cs b/src/Armada.Core/Settings/OAuth2Settings.cs new file mode 100644 index 000000000..4d5974312 --- /dev/null +++ b/src/Armada.Core/Settings/OAuth2Settings.cs @@ -0,0 +1,149 @@ +namespace Armada.Core.Settings +{ + using System; + + /// + /// Settings for generic OAuth2 / OIDC single sign-on to the web dashboard + /// (e.g. Authentik). When enabled, the dashboard offers a redirect-based + /// Authorization-Code login that mints a normal Armada session token. + /// + public class OAuth2Settings + { + #region Public-Members + + /// + /// Whether OAuth2 / OIDC single sign-on is enabled. + /// + public bool Enabled { get; set; } = false; + + /// + /// Label shown on the dashboard sign-in button (e.g. "Authentik"). + /// + public string DisplayName + { + get => _DisplayName; + set => _DisplayName = string.IsNullOrWhiteSpace(value) ? "Single Sign-On" : value; + } + + /// + /// Provider authorization endpoint URL + /// (Authentik: https://authentik.example.com/application/o/authorize/). + /// + public string? AuthorizationEndpoint { get; set; } = null; + + /// + /// Provider token endpoint URL + /// (Authentik: https://authentik.example.com/application/o/token/). + /// + public string? TokenEndpoint { get; set; } = null; + + /// + /// Provider userinfo endpoint URL + /// (Authentik: https://authentik.example.com/application/o/userinfo/). + /// + public string? UserInfoEndpoint { get; set; } = null; + + /// + /// OAuth2 client identifier issued by the provider. + /// + public string? ClientId { get; set; } = null; + + /// + /// OAuth2 client secret issued by the provider. + /// + public string? ClientSecret { get; set; } = null; + + /// + /// Space-delimited scopes to request. + /// + public string Scopes + { + get => _Scopes; + set => _Scopes = string.IsNullOrWhiteSpace(value) ? "openid profile email" : value; + } + + /// + /// Explicit redirect URI registered with the provider. When null or empty, + /// the callback URI is derived from the incoming request host plus + /// "/api/v1/auth/oauth/callback". + /// + public string? RedirectUri { get; set; } = null; + + /// + /// Whether to use PKCE (Proof Key for Code Exchange). Recommended. + /// + public bool UsePkce { get; set; } = true; + + /// + /// Userinfo claim used as the user's email / identity. + /// + public string EmailClaim + { + get => _EmailClaim; + set => _EmailClaim = string.IsNullOrWhiteSpace(value) ? "email" : value; + } + + /// + /// Userinfo claim used as the user's display name. + /// + public string NameClaim + { + get => _NameClaim; + set => _NameClaim = string.IsNullOrWhiteSpace(value) ? "name" : value; + } + + /// + /// Whether to require the provider's "email_verified" claim to be true + /// before trusting the email for identity mapping. Prevents account + /// takeover via an unverified, attacker-chosen email. Leave enabled + /// unless the provider is known not to emit the claim. + /// + public bool RequireVerifiedEmail { get; set; } = true; + + /// + /// Whether to auto-provision a new Armada user on first successful SSO + /// login. When false, only users an admin has already created (matched + /// by email in the default tenant) may sign in. + /// + public bool AllowAutoProvision { get; set; } = true; + + /// + /// Tenant into which SSO users are mapped / provisioned. + /// + public string DefaultTenantId + { + get => _DefaultTenantId; + set => _DefaultTenantId = string.IsNullOrWhiteSpace(value) ? Constants.DefaultTenantId : value; + } + + #endregion + + #region Private-Members + + private string _DisplayName = "Single Sign-On"; + private string _Scopes = "openid profile email"; + private string _EmailClaim = "email"; + private string _NameClaim = "name"; + private string _DefaultTenantId = Constants.DefaultTenantId; + + #endregion + + #region Public-Methods + + /// + /// Whether the settings are complete enough to run the OAuth2 flow. + /// + /// True if enabled and all required endpoints/credentials are present. + public bool IsConfigured() + { + return Enabled + && !string.IsNullOrWhiteSpace(AuthorizationEndpoint) + && !string.IsNullOrWhiteSpace(TokenEndpoint) + && !string.IsNullOrWhiteSpace(UserInfoEndpoint) + && !string.IsNullOrWhiteSpace(ClientId) + && !string.IsNullOrWhiteSpace(ClientSecret); + } + + #endregion + } +} diff --git a/src/Armada.Dashboard/src/App.css b/src/Armada.Dashboard/src/App.css index f8d31b375..de952f859 100644 --- a/src/Armada.Dashboard/src/App.css +++ b/src/Armada.Dashboard/src/App.css @@ -2844,6 +2844,11 @@ thead tr.column-filter-row td input.col-filter::placeholder { .tenant-btn:hover { background: var(--bg-card); border-color: var(--accent); } .link-btn { background: none; color: var(--accent); padding: 4px 0; font-size: 13px; border: none; cursor: pointer; } .link-btn:hover { text-decoration: underline; } +.login-sso-button { width: 100%; background: var(--accent); color: #fff; padding: 10px; font-weight: 600; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; } +.login-sso-button:hover { background: var(--accent-hover); } +.login-divider { display: flex; align-items: center; text-align: center; color: var(--text-dim); font-size: 12px; margin: 18px 0; } +.login-divider::before, .login-divider::after { content: ''; flex: 1; border-bottom: 1px solid var(--border); } +.login-divider span { padding: 0 12px; text-transform: uppercase; letter-spacing: 0.05em; } .login-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border); } .login-footer .github-link { color: var(--text-dim); opacity: 0.5; transition: opacity 0.15s; display: inline-flex; } .login-footer .github-link:hover { opacity: 1; } diff --git a/src/Armada.Dashboard/src/api/client.ts b/src/Armada.Dashboard/src/api/client.ts index 4f0799686..dffa4b535 100644 --- a/src/Armada.Dashboard/src/api/client.ts +++ b/src/Armada.Dashboard/src/api/client.ts @@ -503,6 +503,19 @@ export async function logoutProxy(): Promise { export const lookupTenants = (email: string) => post('/api/v1/tenants/lookup', { Email: email }); +// ==================== OAuth2 / SSO ==================== +export interface OAuthConfig { + enabled: boolean; + displayName: string; +} + +export const getOAuthConfig = () => get('/api/v1/auth/oauth/config'); + +/** Full-page redirect target that begins the OAuth2 single sign-on flow. */ +export function oauthAuthorizeUrl(): string { + return `${BASE_URL}/api/v1/auth/oauth/authorize`; +} + // ==================== Tenants (admin) ==================== export const listTenants = () => get>('/api/v1/tenants'); export const createTenant = (data: Partial) => post('/api/v1/tenants', data); diff --git a/src/Armada.Dashboard/src/components/LoginFlow.tsx b/src/Armada.Dashboard/src/components/LoginFlow.tsx index e3cbba666..9a21ec736 100644 --- a/src/Armada.Dashboard/src/components/LoginFlow.tsx +++ b/src/Armada.Dashboard/src/components/LoginFlow.tsx @@ -1,8 +1,8 @@ -import { useState, type FormEvent } from 'react'; +import { useState, useEffect, type FormEvent } from 'react'; import { useAuth } from '../context/AuthContext'; import { useLocale } from '../context/LocaleContext'; import { useTheme } from '../context/ThemeContext'; -import { lookupTenants, authenticate } from '../api/client'; +import { lookupTenants, authenticate, getOAuthConfig, oauthAuthorizeUrl, type OAuthConfig } from '../api/client'; import type { TenantListEntry } from '../types/models'; import LanguageSelector from './shared/LanguageSelector'; @@ -11,9 +11,10 @@ type LoginMode = 'email' | 'apikey'; type RevealField = 'password' | 'apikey'; export default function LoginFlow() { - const { login } = useAuth(); + const { login, oauthError, clearOauthError } = useAuth(); const { t } = useLocale(); const { darkMode, toggleTheme } = useTheme(); + const [oauthConfig, setOauthConfig] = useState(null); const [mode, setMode] = useState('email'); const [step, setStep] = useState('email'); const [email, setEmail] = useState(''); @@ -25,6 +26,17 @@ export default function LoginFlow() { const [busy, setBusy] = useState(false); const [revealedField, setRevealedField] = useState(null); + useEffect(() => { + getOAuthConfig() + .then((cfg) => setOauthConfig(cfg.enabled ? cfg : null)) + .catch(() => setOauthConfig(null)); + }, []); + + function handleSsoLogin() { + clearOauthError(); + window.location.assign(oauthAuthorizeUrl()); + } + function beginReveal(field: RevealField) { setRevealedField(field); } @@ -105,6 +117,15 @@ export default function LoginFlow() {

Armada

+ {oauthConfig && ( + <> + +
{t('or')}
+ + )} +
+ {oauthError &&
{t('Single sign-on failed.')} ({oauthError})
} {error &&
{error}
} {mode === 'apikey' && ( diff --git a/src/Armada.Dashboard/src/context/AuthContext.tsx b/src/Armada.Dashboard/src/context/AuthContext.tsx index 389b09ebe..ce4fe7590 100644 --- a/src/Armada.Dashboard/src/context/AuthContext.tsx +++ b/src/Armada.Dashboard/src/context/AuthContext.tsx @@ -11,18 +11,37 @@ interface AuthState { isAdmin: boolean; isTenantAdmin: boolean; loading: boolean; + oauthError: string | null; login: (token: string) => Promise; logout: () => void; + clearOauthError: () => void; } const AuthContext = createContext(null); +/** + * Read an OAuth2 callback token/error from the URL fragment and strip it from + * the address bar so the token is not left in browser history. + */ +function consumeOAuthHashToken(): { token: string | null; error: string | null } { + const hash = window.location.hash; + if (!hash || hash.length < 2) return { token: null, error: null }; + const params = new URLSearchParams(hash.slice(1)); + const token = params.get('oauth_token'); + const error = params.get('oauth_error'); + if (token || error) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + return { token, error }; +} + export function AuthProvider({ children }: { children: ReactNode }) { const [sessionToken, setSessionToken] = useState(() => { return localStorage.getItem(SESSION_STORAGE_KEY); }); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [oauthError, setOauthError] = useState(null); const logout = useCallback(() => { setSessionToken(null); @@ -35,8 +54,37 @@ export function AuthProvider({ children }: { children: ReactNode }) { setOnUnauthorized(logout); }, [logout]); - // Restore session on mount + const login = useCallback(async (token: string) => { + setLoading(true); + try { + setSessionToken(token); + setAuthToken(token); + localStorage.setItem(SESSION_STORAGE_KEY, token); + const me = await whoami(); + setUser(me); + } catch { + setSessionToken(null); + setAuthToken(null); + localStorage.removeItem(SESSION_STORAGE_KEY); + throw new Error('Login failed'); + } finally { + setLoading(false); + } + }, []); + + const clearOauthError = useCallback(() => setOauthError(null), []); + + // Restore session on mount (handling an OAuth2 callback fragment first) useEffect(() => { + const oauth = consumeOAuthHashToken(); + if (oauth.token) { + login(oauth.token).catch(() => setOauthError('login_failed')).finally(() => setLoading(false)); + return; + } + if (oauth.error) { + setOauthError(oauth.error); + } + const storedToken = localStorage.getItem(SESSION_STORAGE_KEY); if (storedToken) { setAuthToken(storedToken); @@ -57,30 +105,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); // eslint-disable-line react-hooks/exhaustive-deps - const login = useCallback(async (token: string) => { - setLoading(true); - try { - setSessionToken(token); - setAuthToken(token); - localStorage.setItem(SESSION_STORAGE_KEY, token); - const me = await whoami(); - setUser(me); - } catch { - setSessionToken(null); - setAuthToken(null); - localStorage.removeItem(SESSION_STORAGE_KEY); - throw new Error('Login failed'); - } finally { - setLoading(false); - } - }, []); - const isAuthenticated = !!sessionToken && !!user; const isAdmin = user?.user?.isAdmin ?? false; const isTenantAdmin = isAdmin || (user?.user?.isTenantAdmin ?? false); return ( - + {children} ); diff --git a/src/Armada.Server/ArmadaServer.cs b/src/Armada.Server/ArmadaServer.cs index fe01c6455..e10c5e39f 100644 --- a/src/Armada.Server/ArmadaServer.cs +++ b/src/Armada.Server/ArmadaServer.cs @@ -103,6 +103,7 @@ public class ArmadaServer private ISessionTokenService _SessionTokenService = null!; private IAuthenticationService _AuthenticationService = null!; private IAuthorizationService _AuthorizationService = null!; + private IOAuth2Service _OAuth2Service = null!; private IMissionService _MissionService = null!; private CaptainToolService _CaptainTools = null!; @@ -311,6 +312,7 @@ public async Task StartAsync() } _AuthenticationService = new AuthenticationService(_Database, _SessionTokenService, _Settings, _Logging); _AuthorizationService = new AuthorizationService(); + _OAuth2Service = new OAuth2Service(_Database, _SessionTokenService, _Settings, _Logging); // Seed synthetic admin identity if API key is configured if (!string.IsNullOrEmpty(_Settings.ApiKey)) @@ -711,6 +713,10 @@ private void RegisterRoutes() new AuthRoutes(_SessionTokenService, _AuthenticationService, _Database, _Settings, _JsonOptions) .Register(_App, authenticate, _AuthorizationService); + // OAuth2 / OIDC single sign-on (Authentik and other providers) + new OAuthRoutes(_OAuth2Service, _Settings) + .Register(_App, authenticate, _AuthorizationService); + // Tenants, users, credentials new TenantRoutes(_Database, _JsonOptions) .Register(_App, authenticate, _AuthorizationService); diff --git a/src/Armada.Server/Routes/OAuthRoutes.cs b/src/Armada.Server/Routes/OAuthRoutes.cs new file mode 100644 index 000000000..a134e298e --- /dev/null +++ b/src/Armada.Server/Routes/OAuthRoutes.cs @@ -0,0 +1,136 @@ +namespace Armada.Server.Routes +{ + using System; + using System.Threading.Tasks; + using WatsonWebserver; + using WatsonWebserver.Core; + using Armada.Server; + using Armada.Core.Models; + using Armada.Core.Services.Interfaces; + using Armada.Core.Settings; + + /// + /// REST API routes for generic OAuth2 / OIDC single sign-on (Authentik and + /// other providers). Implements the redirect-based Authorization-Code flow: + /// config discovery, authorize redirect, and callback. On success the + /// callback mints an Armada session token and hands it to the dashboard via + /// a URL fragment. + /// + public class OAuthRoutes + { + #region Private-Members + + private const string CallbackPath = "/api/v1/auth/oauth/callback"; + private readonly IOAuth2Service _oauthService; + private readonly ArmadaSettings _settings; + + #endregion + + #region Constructors-and-Factories + + /// + /// Instantiate. + /// + /// OAuth2 service. + /// Application settings. + public OAuthRoutes(IOAuth2Service oauthService, ArmadaSettings settings) + { + _oauthService = oauthService ?? throw new ArgumentNullException(nameof(oauthService)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + #endregion + + #region Public-Methods + + /// + /// Register routes with the application. + /// + /// Webserver. + /// Authentication middleware (unused; these routes are public). + /// Authorization service (unused; these routes are public). + public void Register( + Webserver app, + Func> authenticate, + IAuthorizationService authz) + { + // Public config so the dashboard can show/hide the SSO button + app.Get("/api/v1/auth/oauth/config", (ApiRequest req) => + { + return Task.FromResult(_oauthService.GetPublicConfig()); + }, + api => api.WithTag("Authentication").WithSummary("OAuth2 single sign-on public configuration")); + + // Begin login: redirect to the provider's authorization endpoint + app.Get("/api/v1/auth/oauth/authorize", (ApiRequest req) => + { + if (!_oauthService.IsEnabled) + return Task.FromResult(RedirectToDashboard(req, "#oauth_error=sso_disabled")); + + string redirectUri = ResolveRedirectUri(req); + string authorizeUrl = _oauthService.BuildAuthorizationUrl(redirectUri); + return Task.FromResult(Redirect(req, authorizeUrl)); + }, + api => api.WithTag("Authentication").WithSummary("Begin OAuth2 single sign-on")); + + // Provider callback: exchange the code and hand a session token to the dashboard + app.Get(CallbackPath, async (ApiRequest req) => + { + string? code = req.Query.GetValueOrDefault("code"); + string? state = req.Query.GetValueOrDefault("state"); + string? providerError = req.Query.GetValueOrDefault("error"); + + if (!string.IsNullOrEmpty(providerError)) + return RedirectToDashboard(req, "#oauth_error=" + Uri.EscapeDataString(providerError)); + + string redirectUri = ResolveRedirectUri(req); + OAuthLoginResult result = await _oauthService.CompleteLoginAsync(code, state, redirectUri).ConfigureAwait(false); + + if (!result.Success || string.IsNullOrEmpty(result.Token)) + return RedirectToDashboard(req, "#oauth_error=" + Uri.EscapeDataString(result.ErrorMessage ?? "login_failed")); + + return RedirectToDashboard(req, "#oauth_token=" + Uri.EscapeDataString(result.Token)); + }, + api => api.WithTag("Authentication").WithSummary("OAuth2 single sign-on callback")); + } + + #endregion + + #region Private-Methods + + private object Redirect(ApiRequest req, string location) + { + // The callback's Location fragment carries a live session token, so the + // token must not also be echoed into the response body (which the + // request-history pipeline can capture). Return an empty body. + req.Http.Response.StatusCode = 302; + req.Http.Response.Headers.Add("Location", location); + return new { }; + } + + private object RedirectToDashboard(ApiRequest req, string fragment) + { + return Redirect(req, "/dashboard" + fragment); + } + + private string ResolveRedirectUri(ApiRequest req) + { + if (!string.IsNullOrWhiteSpace(_settings.OAuth2.RedirectUri)) + return _settings.OAuth2.RedirectUri!; + + string? scheme = req.Http.Request.Headers.Get("X-Forwarded-Proto"); + if (string.IsNullOrWhiteSpace(scheme)) + scheme = _settings.Rest.Ssl ? "https" : "http"; + + string? host = req.Http.Request.Headers.Get("X-Forwarded-Host"); + if (string.IsNullOrWhiteSpace(host)) + host = req.Http.Request.Headers.Get("Host"); + if (string.IsNullOrWhiteSpace(host)) + host = _settings.Rest.Hostname + ":" + _settings.AdmiralPort; + + return scheme + "://" + host + CallbackPath; + } + + #endregion + } +} diff --git a/test/Armada.Test.Automated/Program.cs b/test/Armada.Test.Automated/Program.cs index c99bece36..bba11437c 100644 --- a/test/Armada.Test.Automated/Program.cs +++ b/test/Armada.Test.Automated/Program.cs @@ -113,6 +113,7 @@ public static async Task Main(string[] args) runner.AddSuite(new LogTests(authClient, unauthClient, tempDir)); runner.AddSuite(new AuthenticationTests(authClient, unauthClient, baseUrl, apiKey)); runner.AddSuite(new AuthApiTests(authClient, unauthClient, baseUrl, apiKey)); + runner.AddSuite(new OAuthApiTests(unauthClient, baseUrl)); runner.AddSuite(new CrossTenantApiTests(authClient, unauthClient, baseUrl, apiKey)); runner.AddSuite(new McpToolTests(mcpClient)); runner.AddSuite(new WebSocketTests(authClient, unauthClient, restPort, apiKey)); diff --git a/test/Armada.Test.Automated/Suites/OAuthApiTests.cs b/test/Armada.Test.Automated/Suites/OAuthApiTests.cs new file mode 100644 index 000000000..9ef12dd4c --- /dev/null +++ b/test/Armada.Test.Automated/Suites/OAuthApiTests.cs @@ -0,0 +1,80 @@ +namespace Armada.Test.Automated.Suites +{ + using System; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using Armada.Core.Models; + using Armada.Test.Common; + + /// + /// REST-level integration tests for the OAuth2 single sign-on endpoints. The + /// test server does not configure a provider, so SSO is expected to report + /// disabled and the authorize endpoint should short-circuit safely. + /// + public class OAuthApiTests : TestSuite + { + #region Public-Members + + /// + /// Name of this test suite. + /// + public override string Name => "OAuth API Tests"; + + #endregion + + #region Private-Members + + private HttpClient _UnauthClient; + private string _BaseUrl; + + #endregion + + #region Constructors-and-Factories + + /// + /// Create a new OAuthApiTests suite. + /// + /// Unauthenticated HTTP client. + /// Server base URL. + public OAuthApiTests(HttpClient unauthClient, string baseUrl) + { + _UnauthClient = unauthClient ?? throw new ArgumentNullException(nameof(unauthClient)); + _BaseUrl = baseUrl ?? throw new ArgumentNullException(nameof(baseUrl)); + } + + #endregion + + #region Protected-Methods + + /// + protected override async Task RunTestsAsync() + { + await RunTest("OAuthConfig_WithoutAuth_ReturnsDisabled", async () => + { + HttpResponseMessage response = await _UnauthClient.GetAsync("/api/v1/auth/oauth/config").ConfigureAwait(false); + AssertEqual(HttpStatusCode.OK, response.StatusCode); + + OAuthConfigResult result = await JsonHelper.DeserializeAsync(response).ConfigureAwait(false); + AssertFalse(result.Enabled, "SSO should be disabled on the test server"); + }).ConfigureAwait(false); + + await RunTest("OAuthAuthorize_WhenDisabled_RedirectsWithError", async () => + { + HttpClientHandler handler = new HttpClientHandler(); + handler.AllowAutoRedirect = false; + using (HttpClient noFollow = new HttpClient(handler)) + { + noFollow.BaseAddress = new Uri(_BaseUrl); + HttpResponseMessage response = await noFollow.GetAsync("/api/v1/auth/oauth/authorize").ConfigureAwait(false); + + AssertEqual(HttpStatusCode.Found, response.StatusCode); + AssertNotNull(response.Headers.Location, "Expected a redirect Location"); + AssertContains("oauth_error=sso_disabled", response.Headers.Location!.ToString()); + } + }).ConfigureAwait(false); + } + + #endregion + } +} diff --git a/test/Armada.Test.Unit/Program.cs b/test/Armada.Test.Unit/Program.cs index f26ac33b2..42721b1a3 100644 --- a/test/Armada.Test.Unit/Program.cs +++ b/test/Armada.Test.Unit/Program.cs @@ -117,6 +117,7 @@ public static async Task Main(string[] args) runner.AddSuite(new AuthorizationConfigTests()); runner.AddSuite(new AuthorizationServiceTests()); runner.AddSuite(new AuthEndpointTests()); + runner.AddSuite(new OAuth2ServiceTests()); runner.AddSuite(new PromptTemplateServiceTests()); runner.AddSuite(new PromptSignalConsistencyTests()); runner.AddSuite(new PersonaSeedServiceTests()); diff --git a/test/Armada.Test.Unit/Suites/Services/OAuth2ServiceTests.cs b/test/Armada.Test.Unit/Suites/Services/OAuth2ServiceTests.cs new file mode 100644 index 000000000..934953368 --- /dev/null +++ b/test/Armada.Test.Unit/Suites/Services/OAuth2ServiceTests.cs @@ -0,0 +1,239 @@ +namespace Armada.Test.Unit.Suites.Services +{ + using Armada.Core.Database.Sqlite; + using Armada.Core.Models; + using Armada.Core.Services; + using Armada.Core.Settings; + using Armada.Test.Common; + using Armada.Test.Unit.TestHelpers; + using SyslogLogging; + + public class OAuth2ServiceTests : TestSuite + { + public override string Name => "OAuth2Service"; + + protected override async Task RunTestsAsync() + { + // ---------------------------------------------------------------- + // PKCE (RFC 7636) + // ---------------------------------------------------------------- + + await RunTest("Pkce ComputeCodeChallenge matches RFC 7636 test vector", () => + { + // RFC 7636 Appendix B reference verifier/challenge pair. + string verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + string challenge = PkceHelper.ComputeCodeChallenge(verifier); + AssertEqual("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge); + }); + + await RunTest("Pkce GenerateCodeVerifier is URL-safe", () => + { + string verifier = PkceHelper.GenerateCodeVerifier(); + AssertTrue(verifier.Length >= 43, "Verifier should be at least 43 chars"); + AssertFalse(verifier.Contains('+') || verifier.Contains('/') || verifier.Contains('='), "Verifier must be base64url"); + }); + + // ---------------------------------------------------------------- + // State store (single-use, correlation) + // ---------------------------------------------------------------- + + await RunTest("StateStore Issue then Consume returns the flow once", () => + { + OAuthStateStore store = new OAuthStateStore(); + string state = store.Issue("verifier-123"); + + OAuthFlowState? first = store.Consume(state); + AssertNotNull(first, "First consume should return the flow"); + AssertEqual("verifier-123", first!.CodeVerifier); + + OAuthFlowState? second = store.Consume(state); + AssertNull(second, "Second consume must return null (single-use)"); + }); + + await RunTest("StateStore Consume unknown state returns null", () => + { + OAuthStateStore store = new OAuthStateStore(); + AssertNull(store.Consume("never-issued"), "Unknown state should return null"); + AssertNull(store.Consume(null), "Null state should return null"); + }); + + // ---------------------------------------------------------------- + // Settings gating + // ---------------------------------------------------------------- + + await RunTest("Settings IsConfigured false when disabled or incomplete", () => + { + OAuth2Settings cfg = new OAuth2Settings(); + AssertFalse(cfg.IsConfigured(), "Default settings are not configured"); + + cfg.Enabled = true; + AssertFalse(cfg.IsConfigured(), "Enabled but missing endpoints is not configured"); + }); + + await RunTest("Settings IsConfigured true when complete", () => + { + OAuth2Settings cfg = BuildConfiguredSettings(); + AssertTrue(cfg.IsConfigured(), "Fully populated settings should be configured"); + }); + + // ---------------------------------------------------------------- + // Authorization URL + // ---------------------------------------------------------------- + + await RunTest("BuildAuthorizationUrl includes required OAuth params with PKCE", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + OAuth2Service svc = CreateService(testDb.Driver, BuildConfiguredSettings()); + string url = svc.BuildAuthorizationUrl("https://armada.example.com/api/v1/auth/oauth/callback"); + + AssertContains("response_type=code", url); + AssertContains("client_id=test-client", url); + AssertContains("code_challenge=", url); + AssertContains("code_challenge_method=S256", url); + AssertContains("state=", url); + AssertStartsWith("https://idp.example.com/authorize", url); + } + }); + + await RunTest("BuildAuthorizationUrl omits PKCE when disabled", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.UsePkce = false; + OAuth2Service svc = CreateService(testDb.Driver, cfg); + string url = svc.BuildAuthorizationUrl("https://armada.example.com/api/v1/auth/oauth/callback"); + + AssertFalse(url.Contains("code_challenge"), "No PKCE challenge when disabled"); + } + }); + + // ---------------------------------------------------------------- + // User resolution / provisioning + // ---------------------------------------------------------------- + + await RunTest("ResolveOrProvisionUser returns existing user", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + SqliteDatabaseDriver db = testDb.Driver; + TenantMetadata tenant = new TenantMetadata("Test Tenant"); + await db.Tenants.CreateAsync(tenant); + UserMaster user = new UserMaster(tenant.Id, "existing@example.com", "password"); + await db.Users.CreateAsync(user); + + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.DefaultTenantId = tenant.Id; + OAuth2Service svc = CreateService(db, cfg); + + UserMaster? resolved = await svc.ResolveOrProvisionUserAsync("existing@example.com", null); + AssertNotNull(resolved, "Existing user should resolve"); + AssertEqual(user.Id, resolved!.Id); + } + }); + + await RunTest("ResolveOrProvisionUser auto-provisions a new user", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + SqliteDatabaseDriver db = testDb.Driver; + TenantMetadata tenant = new TenantMetadata("Test Tenant"); + await db.Tenants.CreateAsync(tenant); + + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.DefaultTenantId = tenant.Id; + cfg.AllowAutoProvision = true; + OAuth2Service svc = CreateService(db, cfg); + + UserMaster? resolved = await svc.ResolveOrProvisionUserAsync("new@example.com", "Jane Doe"); + AssertNotNull(resolved, "New user should be provisioned"); + AssertEqual("new@example.com", resolved!.Email); + AssertEqual("Jane", resolved.FirstName); + AssertEqual("Doe", resolved.LastName); + AssertFalse(resolved.IsAdmin, "Provisioned user should not be admin"); + + UserMaster? persisted = await db.Users.ReadByEmailAsync(tenant.Id, "new@example.com"); + AssertNotNull(persisted, "Provisioned user should be persisted"); + } + }); + + await RunTest("ResolveOrProvisionUser rejects unknown user when auto-provision disabled", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + SqliteDatabaseDriver db = testDb.Driver; + TenantMetadata tenant = new TenantMetadata("Test Tenant"); + await db.Tenants.CreateAsync(tenant); + + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.DefaultTenantId = tenant.Id; + cfg.AllowAutoProvision = false; + OAuth2Service svc = CreateService(db, cfg); + + UserMaster? resolved = await svc.ResolveOrProvisionUserAsync("nobody@example.com", null); + AssertNull(resolved, "Unknown user should be rejected when provisioning disabled"); + } + }); + + await RunTest("ResolveOrProvisionUser rejects when tenant missing", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.DefaultTenantId = "ten_does_not_exist"; + OAuth2Service svc = CreateService(testDb.Driver, cfg); + + UserMaster? resolved = await svc.ResolveOrProvisionUserAsync("someone@example.com", null); + AssertNull(resolved, "Should reject when the configured tenant does not exist"); + } + }); + + // ---------------------------------------------------------------- + // Public config + // ---------------------------------------------------------------- + + await RunTest("GetPublicConfig reflects enabled state and display name", async () => + { + using (TestDatabase testDb = await TestDatabaseHelper.CreateDatabaseAsync()) + { + OAuth2Settings cfg = BuildConfiguredSettings(); + cfg.DisplayName = "Authentik"; + OAuth2Service svc = CreateService(testDb.Driver, cfg); + + OAuthConfigResult config = svc.GetPublicConfig(); + AssertTrue(config.Enabled, "Should report enabled"); + AssertEqual("Authentik", config.DisplayName); + } + }); + } + + #region Private-Helpers + + private static OAuth2Settings BuildConfiguredSettings() + { + OAuth2Settings cfg = new OAuth2Settings(); + cfg.Enabled = true; + cfg.AuthorizationEndpoint = "https://idp.example.com/authorize"; + cfg.TokenEndpoint = "https://idp.example.com/token"; + cfg.UserInfoEndpoint = "https://idp.example.com/userinfo"; + cfg.ClientId = "test-client"; + cfg.ClientSecret = "test-secret"; + return cfg; + } + + private static OAuth2Service CreateService(SqliteDatabaseDriver db, OAuth2Settings oauth) + { + LoggingModule logging = new LoggingModule(); + logging.Settings.EnableConsole = false; + + ArmadaSettings settings = new ArmadaSettings(); + settings.OAuth2 = oauth; + + SessionTokenService tokenSvc = new SessionTokenService(); + return new OAuth2Service(db, tokenSvc, settings, logging); + } + + #endregion + } +}