Skip to content

Altium.Auth

CI license

Altium 365 OAuth2 / OpenID Connect authentication client for .NET. Supports both client types as well as the different deployment types: Commercial Cloud, GovCloud, and AES (on-prem).

  • Public clients (desktop) — browser sign-in with PKCE over Altium's ActionWait long-poll: SignInAsync.
  • Confidential clients (web/server backends with a secret) — the standard authorization-code redirect flow via composable steps: CreateAuthorizationUrl and ExchangeCodeAsync.
  • Workspace tokens, token refresh / revocation, and first-class GovCloud and AES (on-prem) support.

Zero dependencies on net10.0, net8.0, and netstandard2.0 (.NET Framework 4.6.1+, including 4.8). Validated against the same language-neutral conformance vectors as the TypeScript library — see How it's built.

Documentation

The client implements the flow described in these protocol-level guides (independent of this package):

Quick start

Only ClientId and Scopes are required — endpoints default to the Altium 365 Commercial Cloud. You supply the HttpClient (reuse one / use IHttpClientFactory).

Public apps (desktop — ActionWait sign-in)

For apps that can't host a public redirect. SignInAsync invokes your OpenBrowser callback, waits for the callback over ActionWait, and returns tokens.

using Altium.Auth;
using System.Diagnostics;

var http = new HttpClient();
var options = new AltiumAuthOptions
{
    ClientId = "your-client-id",
    Scopes = "openid profile",
    OpenBrowser = url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }),
};
var client = new AltiumAuthClient(http, options);

// Opens the browser and waits for the sign-in callback.
TokenSet tokens = await client.SignInAsync();

// Persist `tokens` yourself — the client never stores them.

// Exchange the global token for a workspace-scoped token.
TokenSet workspaceToken = await client.SignIntoWorkspaceAsync(tokens.AccessToken, "workspace-id-here");

To prompt the user to select a workspace during sign-in (login-into-workspace mode), pass selectWorkspace:

// WorkspaceSelection.Strict   → user must choose a workspace (token is workspace-scoped after exchange)
// WorkspaceSelection.Optional → selection offered; the user may skip it
TokenSet tokens = await client.SignInAsync(WorkspaceSelection.Optional);

See Login-into-workspace mode.

Confidential apps (web / server — authorization-code redirect)

For backends that host their own redirect endpoint. Set ClientSecret to authenticate as a confidential client (HTTP Basic) and drive the flow with two steps.

var options = new AltiumAuthOptions
{
    ClientId = "your-client-id",
    ClientSecret = "your-client-secret", // confidential client → HTTP Basic
    Scopes = "openid profile offline_access",
};
var client = new AltiumAuthClient(http, options);
var redirectUri = "https://my-service.example.com/oauth/callback";

// On your login route: build the URL, stash state + verifier, then redirect.
AuthorizationRequest authz = client.CreateAuthorizationUrl(redirectUri);
// Save authz.State + authz.CodeVerifier (e.g. in the session); redirect to authz.Url.

// On your callback route: verify state matches, then exchange the code.
TokenSet tokens = await client.ExchangeCodeAsync(code, authz.CodeVerifier, redirectUri);

CreateAuthorizationUrl is synchronous (generates PKCE + state); ExchangeCodeAsync performs the token exchange. SignIntoWorkspaceAsync, RefreshTokenAsync, and RevokeRefreshTokenAsync all apply the ClientSecret automatically when it's set.

GovCloud

Use the AltiumEndpoints.GovCloud preset — that's it. The client detects the Gov token endpoint and adds the required secure=1 to token requests automatically (the two-token model); no flag to set.

var options = new AltiumAuthOptions
{
    ClientId = "your-gov-client-id",
    Scopes = "openid profile",
    Endpoints = AltiumEndpoints.GovCloud,
    OpenBrowser = url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }),
};

Commercial and Gov are kept strictly separate: a global token can only be exchanged for a workspace of the matching kind. secure=1 is driven by which token endpoint you use, so pointing the token endpoint at the Gov host is all it takes to exchange a Commercial token for a Gov workspace token. See the GovCloud guide.

AES (on-prem)

Altium Enterprise Server (AES) is a customer-hosted, on-prem installation — unlike Commercial/GovCloud (fixed Altium-hosted domains), there's no fixed host, so use AltiumEndpoints.Aes(origin) to derive the endpoint set from your AES server's origin. AES does not use secure=1 (same rule as Commercial), and there's no cross-cloud bridging to/from Commercial or Gov.

var clientId = "your-aes-client-id";
var endpoints = AltiumEndpoints.Aes("https://aes.server.example:9785");

// AES hosts a single workspace — introspect the exact scope to request for it.
var scopesArray = await AltiumAuthClient.GetClientScopesAsync(http, endpoints.ScopeEndpoint!, clientId);
var scopes = string.Join(" ", scopesArray);

var options = new AltiumAuthOptions
{
    ClientId = clientId,
    Scopes = scopes,
    Endpoints = endpoints,
    OpenBrowser = url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }),
};

See the AES (on-prem) guide.

Refresh & sign-out

// Refresh when the access token has expired (compare TokenSet.ExpiresAt to now).
if (tokens.ExpiresAt is long exp && exp <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()
    && tokens.RefreshToken is not null)
{
    tokens = await client.RefreshTokenAsync(tokens.RefreshToken);
    // Persist again — including a rotated RefreshToken if present.
}

// On sign-out: revoke the refresh token server-side, then discard your local copy.
if (tokens.RefreshToken is not null)
    await client.RevokeRefreshTokenAsync(tokens.RefreshToken);

API reference

Constructor: new AltiumAuthClient(HttpClient http, AltiumAuthOptions options) — implements IAltiumAuthClient.

Member Description
SignInAsync(selectWorkspace, ct) Public-client PKCE sign-in via ActionWait. Uses OpenBrowser to launch the authorization URL. selectWorkspace is a WorkspaceSelection (None default, Strict, Optional). Returns TokenSet.
CreateAuthorizationUrl(redirectUri?, state?, codeVerifier?, selectWorkspace) Build the authorize URL for the redirect flow. Returns AuthorizationRequest. Synchronous. selectWorkspace is a WorkspaceSelection (None default, Strict, Optional).
ExchangeCodeAsync(code, codeVerifier?, redirectUri?, ct) Exchange an authorization code for tokens.
SignIntoWorkspaceAsync(baseAccessToken, workspaceAuthId, ct) Workspace-scoped token via RFC 8693 token-exchange.
RefreshTokenAsync(refreshToken, ct) Refresh via the refresh_token grant (sends no scope — retains the original grant).
RevokeRefreshTokenAsync(refreshToken, ct) Revoke a refresh token (RFC 7009); idempotent.

AltiumAuthOptions

public sealed class AltiumAuthOptions
{
    public required string ClientId { get; init; }
    public required string Scopes { get; init; }          // must include "openid profile"
    public string? ClientSecret { get; init; }            // confidential clients → HTTP Basic
    public AltiumEndpoints Endpoints { get; init; }        // default: AltiumEndpoints.CommercialCloud
    public bool? Secure { get; init; }                     // override Gov auto-detection (normally null)
    public Action<string>? OpenBrowser { get; init; }      // invoked by SignInAsync to open the URL
}

AltiumEndpoints

A record of the endpoints (AuthorizeEndpoint, TokenEndpoint, ActionWaitEndpoint, RedirectUri, ScopeEndpoint) with two fixed presets — AltiumEndpoints.CommercialCloud (default) and AltiumEndpoints.GovCloud — plus AltiumEndpoints.Aes(origin), a factory that derives the endpoint set for an AES (on-prem) installation from its server origin, including ScopeEndpoint for scope introspection — use AltiumAuthClient.GetClientScopesAsync (GET with a clientId query parameter) to discover the exact a365:workspace:{id} scope for the installation's single workspace. ScopeEndpoint is null in the Cloud presets: the endpoint exists there too ({base}/api/ClientScopes), but a Cloud response carries no a365:workspace:{id} scope — a Cloud client may reach many workspaces, none implied by its client ID — so set it yourself if you want the client's static scopes. Construct your own record for other custom hosts.

TokenSet

public sealed class TokenSet
{
    public string AccessToken { get; set; }
    public string? TokenType { get; set; }
    public int? ExpiresIn { get; set; }
    public long? ExpiresAt { get; set; }   // epoch seconds (computed by the client)
    public string? RefreshToken { get; set; }
    public string? IdToken { get; set; }
    public string? Scope { get; set; }
}

AccessToken is a signed JWT — decode it to read iss, workspaceId, secure, and scopes. See Access token claims.

Error handling

Methods throw on empty required arguments and on non-success responses. The message includes the HTTP status and the OAuth error/error_description when present — e.g. a Gov workspace exchange on a Commercial endpoint surfaces access_denied; a refresh with a revoked/expired token surfaces invalid_grant. ActionWait failures surface a descriptive message (timeout, cancellation, or a CSRF state mismatch).

Compatibility

  • net10.0, net8.0, and netstandard2.0 — the last covering .NET Framework 4.6.1+ (4.8 included) and any other netstandard2.0 runtime.
  • Dependency-free on every target. JSON is read with the BCL's DataContractJsonSerializer, so a .NET Framework project installs the package without System.Text.Json or its transitive assemblies and the binding redirects they bring.
  • You provide the HttpClient; the client sets headers/bodies but does not own the transport.

How it's built

Altium.Auth is dependency-free by design: it acquires tokens and never validates JWTs, so it needs no OIDC/JWT library. Its behavior is pinned by the shared, language-neutral vectors in spec/conformance/vectors.json — the exact contract the TypeScript library passes. This proves the two implementations are behavior-identical. Spec: spec/SPEC.md.

Development

Open Altium.Auth.sln in your IDE (library, tests, and tools grouped into src/tests/tools folders), or from the CLI:

# Build / test the whole solution
dotnet build Altium.Auth.sln -c Release
dotnet test Altium.Auth.sln -c Release      # runs the xUnit conformance suite

# Or target a single project:
dotnet test tests/Altium.Auth.Tests -c Release

The vectors run on every target the package ships. net48 is included only on Windows (there is no runtime to host it elsewhere), so it runs in CI and in a local dotnet test on Windows — that leg is what proves the netstandard2.0 asset works on .NET Framework, not just that it compiles.

The shipped library (src/Altium.Auth) builds with analyzers + TreatWarningsAsErrors (see .editorconfig), so dotnet build is the style/quality gate.

Live E2E against a real environment

tools/SignInTest runs the real flow (ActionWait sign-in, workspace exchange, refresh, revoke) against a live environment — needs network + a browser:

# Commercial, public client
dotnet run --project tools/SignInTest -- YOUR_CLIENT_ID

# Dev Gov — verifies the secure=1 two-token model
dotnet run --project tools/SignInTest -- --env dev-gov YOUR_GOV_CLIENT_ID

# AES (on-prem) — verifies the origin-derived endpoints, no secure=1
dotnet run --project tools/SignInTest -- --env aes --aes-origin https://aes.server.example:9785 YOUR_AES_CLIENT_ID

# Confidential client (HTTP Basic) — secret via env, never on the CLI
A365_CLIENT_SECRET=... dotnet run --project tools/SignInTest -- --workspace <authId> --revoke YOUR_CLIENT_ID

Options mirror the TS harness: --env prod|dev|gov|dev-gov|aes, --aes-origin (required for --env aes), --workspace-env, --secure/--no-secure, --scopes, --workspace, --refresh, --revoke, --userinfo, and --authorize-url/--exchange-code/--code-verifier/--redirect-uri for confidential/custom-callback clients.

See CONTRIBUTING.md and AGENTS.md for the repo-wide, spec-first contribution model.

Security

Please report vulnerabilities privately — see SECURITY.md.

License

MIT © Altium Limited

Comments