Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/REST_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<session-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": "<client-secret>",
"RedirectUri": "https://armada.example.com/api/v1/auth/oauth/callback"
}
```

Register `https://<armada-host>/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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions src/Armada.Core/Authorization/AuthorizationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
23 changes: 23 additions & 0 deletions src/Armada.Core/Models/OAuthConfigResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Armada.Core.Models
{
/// <summary>
/// Public OAuth2 configuration surfaced to the dashboard so it can show or
/// hide the single sign-on button. Contains no secrets.
/// </summary>
public class OAuthConfigResult
{
#region Public-Members

/// <summary>
/// Whether OAuth2 single sign-on is enabled and fully configured.
/// </summary>
public bool Enabled { get; set; } = false;

/// <summary>
/// Label to show on the sign-in button.
/// </summary>
public string DisplayName { get; set; } = "Single Sign-On";

#endregion
}
}
26 changes: 26 additions & 0 deletions src/Armada.Core/Models/OAuthFlowState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Armada.Core.Models
{
using System;

/// <summary>
/// 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.
/// </summary>
public class OAuthFlowState
{
#region Public-Members

/// <summary>
/// PKCE code verifier generated when the flow started.
/// </summary>
public string CodeVerifier { get; set; } = string.Empty;

/// <summary>
/// UTC time after which this flow state is no longer valid.
/// </summary>
public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow;

#endregion
}
}
45 changes: 45 additions & 0 deletions src/Armada.Core/Models/OAuthLoginResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Armada.Core.Models
{
using System;

/// <summary>
/// Result of completing an OAuth2 login (code exchange + user resolution +
/// session token minting).
/// </summary>
public class OAuthLoginResult
{
#region Public-Members

/// <summary>
/// Whether the login succeeded.
/// </summary>
public bool Success { get; set; } = false;

/// <summary>
/// Minted Armada session token (X-Token) when successful.
/// </summary>
public string? Token { get; set; } = null;

/// <summary>
/// Session token expiry when successful.
/// </summary>
public DateTime? ExpiresUtc { get; set; } = null;

/// <summary>
/// Short, safe error reason when unsuccessful (surfaced to the dashboard).
/// </summary>
public string? ErrorMessage { get; set; } = null;

/// <summary>
/// Create a failed result.
/// </summary>
/// <param name="error">Error reason.</param>
/// <returns>Failed result.</returns>
public static OAuthLoginResult Failed(string error)
{
return new OAuthLoginResult { Success = false, ErrorMessage = error };
}

#endregion
}
}
56 changes: 56 additions & 0 deletions src/Armada.Core/Models/OAuthTokenResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace Armada.Core.Models
{
using System.Text.Json.Serialization;

/// <summary>
/// Strongly-typed OAuth2 token endpoint response.
/// </summary>
public class OAuthTokenResponse
{
#region Public-Members

/// <summary>
/// Access token used to call the userinfo endpoint.
/// </summary>
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; } = null;

/// <summary>
/// Token type (typically "Bearer").
/// </summary>
[JsonPropertyName("token_type")]
public string? TokenType { get; set; } = null;

/// <summary>
/// OIDC ID token, when the "openid" scope is requested.
/// </summary>
[JsonPropertyName("id_token")]
public string? IdToken { get; set; } = null;

/// <summary>
/// Lifetime of the access token in seconds.
/// </summary>
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; set; } = null;

/// <summary>
/// Granted scopes.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; set; } = null;

/// <summary>
/// Error code returned by the provider when the exchange fails.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; set; } = null;

/// <summary>
/// Human-readable error description returned by the provider.
/// </summary>
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; } = null;

#endregion
}
}
82 changes: 82 additions & 0 deletions src/Armada.Core/Models/OAuthUserInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
namespace Armada.Core.Models
{
using System.Text.Json.Serialization;

/// <summary>
/// Strongly-typed OIDC userinfo response covering the standard claims
/// Authentik and other generic OAuth2/OIDC providers return.
/// </summary>
public class OAuthUserInfo
{
#region Public-Members

/// <summary>
/// Subject identifier (stable, provider-unique user id).
/// </summary>
[JsonPropertyName("sub")]
public string? Sub { get; set; } = null;

/// <summary>
/// Email address claim.
/// </summary>
[JsonPropertyName("email")]
public string? Email { get; set; } = null;

/// <summary>
/// Whether the provider has verified ownership of the email (OIDC email_verified claim).
/// </summary>
[JsonPropertyName("email_verified")]
public bool? EmailVerified { get; set; } = null;

/// <summary>
/// Preferred username claim.
/// </summary>
[JsonPropertyName("preferred_username")]
public string? PreferredUsername { get; set; } = null;

/// <summary>
/// Full display name claim.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; } = null;

/// <summary>
/// Given (first) name claim.
/// </summary>
[JsonPropertyName("given_name")]
public string? GivenName { get; set; } = null;

/// <summary>
/// Family (last) name claim.
/// </summary>
[JsonPropertyName("family_name")]
public string? FamilyName { get; set; } = null;

#endregion

#region Public-Methods

/// <summary>
/// Resolve a configured claim name to its value using the standard claims.
/// </summary>
/// <param name="claimName">Claim name from settings (e.g. "email", "preferred_username", "sub", "name").</param>
/// <returns>Claim value, or null if not present / not a recognized claim.</returns>
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
}
}
54 changes: 54 additions & 0 deletions src/Armada.Core/Services/Interfaces/IOAuth2Service.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace Armada.Core.Services.Interfaces
{
using System.Threading;
using System.Threading.Tasks;
using Armada.Core.Models;

/// <summary>
/// Service for generic OAuth2 / OIDC single sign-on (Authorization-Code flow
/// with PKCE). Exchanges provider codes for identity and mints an Armada
/// session token.
/// </summary>
public interface IOAuth2Service
{
/// <summary>
/// Whether OAuth2 single sign-on is enabled and fully configured.
/// </summary>
bool IsEnabled { get; }

/// <summary>
/// Public, secret-free configuration for the dashboard.
/// </summary>
/// <returns>OAuth config result.</returns>
OAuthConfigResult GetPublicConfig();

/// <summary>
/// Begin an Authorization-Code login: generate state + PKCE, store the
/// flow, and return the full provider authorization URL to redirect to.
/// </summary>
/// <param name="redirectUri">Callback URI the provider will redirect back to.</param>
/// <returns>Provider authorization URL.</returns>
string BuildAuthorizationUrl(string redirectUri);

/// <summary>
/// Complete the login: validate state, exchange the code, fetch userinfo,
/// resolve or provision the user, and mint a session token.
/// </summary>
/// <param name="code">Authorization code from the provider.</param>
/// <param name="state">Opaque state value from the provider redirect.</param>
/// <param name="redirectUri">The same callback URI used to start the flow.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Login result.</returns>
Task<OAuthLoginResult> CompleteLoginAsync(string? code, string? state, string redirectUri, CancellationToken token = default);

/// <summary>
/// Resolve an existing user by email in the configured tenant, or
/// provision a new one when auto-provisioning is enabled.
/// </summary>
/// <param name="email">Email / identity from the provider.</param>
/// <param name="displayName">Optional display name from the provider.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>Resolved user, or null when the user cannot be signed in.</returns>
Task<UserMaster?> ResolveOrProvisionUserAsync(string email, string? displayName, CancellationToken token = default);
}
}
Loading