# Altium Authentication Sign users in to **Altium 365** from your own application and call the Altium 365 API on their behalf. Altium Identity is a standard OAuth 2.0 / OpenID Connect provider; this site covers the parts that are specific to Altium: the **ActionWait** desktop sign-in, workspace token exchange, **GovCloud** and **AES** (on-prem) installations. Client libraries for TypeScript, .NET and Python implement it all, each validated against one shared specification. [Get started](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application.md) [Read the specification](https://altiumdeveloper.github.io/altium-auth/specification/spec.md) ## Choose your flow The flow depends on whether your application can host an HTTPS redirect. Both end with a workspace access token for the Altium 365 API. - [**Desktop apps**](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) `public client · ActionWait`: Browser sign-in with PKCE for apps that cannot host a redirect; the result arrives over a long-poll - [**Web & server apps**](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) `confidential client · redirect`: The standard authorization-code flow for backends that host a callback and keep a client secret Workspaces in **GovCloud** and on customer-hosted **AES** servers change where tokens are issued; see [GovCloud](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) and [AES (on-prem)](https://altiumdeveloper.github.io/altium-auth/guides/aes.md). What is inside a token is in [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims.md). ## Use a library Each library is a hand-written, idiomatic client with no runtime dependencies. All three are in **preview**: the API may change as the specification evolves. - [**TypeScript**](https://altiumdeveloper.github.io/altium-auth/libraries/typescript.md) `npm install @altium-developer/altium-auth`: Node 22+, Bun and Deno: sign-in, code exchange, workspace tokens, refresh and revocation - [**.NET**](https://altiumdeveloper.github.io/altium-auth/libraries/dotnet.md) `dotnet add package Altium.Auth`: .NET 8 and 10, .NET Framework 4.6.1+ through netstandard2.0, and PowerShell - [**Python**](https://altiumdeveloper.github.io/altium-auth/libraries/python.md) `pip install altium-auth`: Python 3.10+, standard library only; call it from async code with asyncio.to_thread ## How the libraries stay in sync The [integration specification](https://altiumdeveloper.github.io/altium-auth/specification/spec.md) defines the behavior, and the [conformance vectors](https://altiumdeveloper.github.io/altium-auth/specification/conformance.md) prove it: every library runs the same `spec/conformance/vectors.json` and asserts the same requests and outcomes, so the implementations cannot drift. ## For AI assistants Every page is also available as Markdown (add `.md` to its URL, or use **View as Markdown**). [`llms.txt`](https://altiumdeveloper.github.io/altium-auth/llms.txt) indexes the site, and [`llms-full.txt`](https://altiumdeveloper.github.io/altium-auth/llms-full.txt) has every page in one file. --- # Authentication Altium Identity is Altium's OAuth 2.0 and OpenID Connect (OIDC) identity provider. Your application authenticates users through Altium Identity, then calls the Altium 365 API on their behalf. - [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) - [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html) ## Endpoints Altium Identity is available at one base URL per environment: | Name | Base URL | Discovery document | | --- | --- | --- | | Commercial Cloud | https://auth.altium.com | https://auth.altium.com/.well-known/openid-configuration | | GovCloud | https://auth.365-gov.altium.com | https://auth.365-gov.altium.com/.well-known/openid-configuration | | AES (on-prem) | {origin}/unifiedlogin (customer-hosted) | {origin}/unifiedlogin/.well-known/openid-configuration | See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) for the differences that apply to GovCloud, and [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md) for on-prem installations. Every endpoint below is published in each base URL's discovery document: | Discovery key | Path | Purpose | | --- | --- | --- | | `authorization_endpoint` | `/connect/authorize` | Starts sign-in; returns an authorization code after the user authenticates and consents. | | `token_endpoint` | `/connect/token` | Issues tokens — exchanges an authorization code, another access token (token-exchange), or a refresh token. | | `userinfo_endpoint` | `/connect/userinfo` | Returns up-to-date identity claims for the signed-in user (call it with the access token). | | `revocation_endpoint` | `/connect/revocation` | Revokes a refresh token. | Prepend the base URL for your environment — for example, `https://auth.altium.com/connect/token`. ## Key terms - **Global access token** — a user-level token you receive after sign-in with the `openid profile` scopes. Use it to discover which workspaces the user can access. - **Workspace access token** — a token scoped to a single workspace through the `a365:workspace:{workspaceId}` scope. Use it for Altium 365 API calls against that workspace. - **Refresh token** — issued when you request the `offline_access` scope; use it to obtain a new access token (global or workspace) without asking the user to sign in again. - **Workspace context** — the workspace identity carried by the `a365:workspace:{workspaceId}` scope. ## The authentication journey The recommended flow is the same for web and desktop apps — only *how* you obtain the authorization code differs (a redirect you host, or the [ActionWait](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) pattern). In both cases: 1. **Sign in on `https://auth.altium.com`** to get a **global access token**. Use it for accessing global resources such as listing the user's workspaces. 2. **Discover the user's workspaces** with the global token. See [Discover the user's workspaces](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md#step-3--discover-the-users-workspaces). 3. **Exchange the global token for a workspace access token — at the endpoint that matches the workspace:** - **Commercial workspace** → exchange on Commercial Cloud endpoint (`https://auth.altium.com`, no `secure=1`). - **GovCloud workspace** → exchange on GovCloud endpoint (`https://auth.365-gov.altium.com`, with `secure=1`). See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md). 4. **Call the Altium 365 API** with the workspace token, refreshing it at the endpoint that issued it. ```mermaid sequenceDiagram participant App participant Identity as Altium Identity participant API as Altium 365 API App->>Identity: 1. Sign in on auth.altium.com (openid profile) + PKCE Identity-->>App: global access token App->>API: 2. desWorkspaceInfos (global token) API-->>App: workspaces (+ location.name) alt Commercial workspace App->>Identity: 3a. Token exchange @ auth.altium.com Identity-->>App: workspace access token else GovCloud workspace App->>Identity: 3b. Token exchange @ auth.365-gov.altium.com (secure=1) Identity-->>App: Gov workspace access token end App->>API: 4. API calls (workspace access token) ``` An application commonly holds several tokens at once — one global token plus a workspace token per workspace in use (some Commercial, some Gov). Each refreshes at its own issuing endpoint. ### Where to send each request | Operation | Endpoint | | --- | --- | | Cloud sign in + code exchange | `auth.altium.com` | | Commercial workspace token (exchange + refresh) | `auth.altium.com` | | GovCloud workspace token (exchange + refresh) | `auth.365-gov.altium.com` | | AES sign in + workspace token | `{origin}/unifiedlogin` (customer-hosted) | ## Login-into-workspace mode When you know at sign-in time that the user should land on a workspace, you don't need the separate discover-and-exchange steps (steps 2–3) — you can get a workspace-scoped token directly from the initial sign-in: - **You already know the workspace ID.** Include `a365:workspace:{workspaceId}` directly in the `scope` parameter of the initial `/connect/authorize` request, alongside `openid profile` and, if you need a refresh token, `offline_access`. This returns the workspace-scoped access token (and refresh token, if requested) in one round trip. - **You want the user to choose during sign-in.** Use the optional `selectWorkspace` parameter on `/connect/authorize` instead, so the user picks a workspace **as part of the authorization flow** without your application needing to enumerate them first. | `selectWorkspace` value | Behavior | | --- | --- | | omitted or `none` (default) | Workspace selection is skipped; the flow issues a global access token as usual. | | `strict` | Workspace selection is **mandatory** — the user must choose a workspace before authentication can complete. The returned token is already workspace-scoped. | | `optional` | Workspace selection is offered but may be skipped by the user. | Run the *whole* flow on the host that matches the workspace: `auth.altium.com` for a Commercial workspace, or `auth.365-gov.altium.com` for a GovCloud workspace (send `secure=1` on the `/connect/token` requests only). When a workspace is selected, the authorization code exchange returns a workspace-scoped access token directly. See [Step 1](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md#step-1--request-authorization-with-pkce) in the web guide, or the `selectWorkspace` option in the library API references. The `a365:workspace:{workspaceId}` scope plays two roles today: it transfers **workspace context** (which workspace the token is for) and grants **access to that workspace's resources**. It is the same scope across Altium 365 and Altium Enterprise Server. Additional scopes may appear in the discovery document over time; this guide uses `a365:workspace:{workspaceId}`. See the [OAuth Scopes](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/oauth-scopes) key concept. ## Which flow do I need? - **Web or server application** that can host an HTTPS redirect endpoint: [Authenticate a web or server application](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md). - **Desktop application** that cannot host a public redirect: [Authenticate a desktop application](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md). Both guides cover GovCloud workspaces via the exchange branch above. See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) for additional details, or [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md) if you're integrating against an on-prem AES installation. ## Related - [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application.md) - [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims.md) — what's inside a token (`iss`, `workspaceId`, `secure`, scopes) - [Tokens](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/tokens) - [OAuth Scopes](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/oauth-scopes) - [Realms](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/realms) · [GRID](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/grid) --- # Authenticate a web or server application This guide walks through the standard authentication flow for applications that can host an HTTPS redirect endpoint. See [Key terms](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#key-terms) for the token vocabulary used here. **Prerequisites:** a registered application with a `client_id`, `client_secret`, and redirect URL. See [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application.md). ## Flow at a glance ```mermaid sequenceDiagram participant User participant App participant Identity as Altium Identity participant API as Altium 365 API User->>App: Start sign-in App->>Identity: 1️⃣ Authorize (openid profile) + PKCE challenge Identity->>User: Sign-in + consent Identity-->>App: Redirect with authorization code App->>Identity: 2️⃣ Exchange code + PKCE verifier Identity-->>App: Global access token App->>API: 3️⃣ desWorkspaceInfos (global token) API-->>App: Workspaces User->>App: Choose a workspace App->>Identity: 4️⃣ Token exchange (a365:workspace:{id}) Identity-->>App: Workspace access token + (optional) refresh token App->>API: API calls (workspace token) ``` ## Step 1 — Request authorization (with PKCE) Generate a PKCE `code_verifier` (a high-entropy random string) and derive the `code_challenge` as the base64url-encoded SHA-256 of the verifier. Redirect the user to: ``` GET https://auth.altium.com/connect/authorize ?client_id= &response_type=code &scope=openid+profile &redirect_uri=https%3A%2F%2Fyour-app.example.com%2Foauth%2Fcallback &code_challenge= &code_challenge_method=S256 &state= ``` - `redirect_uri` is the callback URL you registered with your application. - `state` is an opaque value you generate and later verify to protect against CSRF. ### Getting a workspace-scoped token directly If you already know the workspace the user should land on, request it up front: include `a365:workspace:` in the `scope` parameter above, and the code exchange in Step 2 directly returns the workspace token (and refresh token, with `offline_access`), skipping Steps 3–4 below. Run the *whole* flow on the host that matches the workspace: `auth.altium.com` for a Commercial workspace, or `auth.365-gov.altium.com` for a GovCloud workspace (send `secure=1` on the `/connect/token` requests only). If you don't know the workspace ahead of time but still want the user to land on one during sign-in, add the optional `selectWorkspace` parameter to the authorize request instead, to have the code exchange return a workspace-scoped token directly. See [Login-into-workspace mode](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#login-into-workspace-mode) for the values (`none` / `strict` / `optional`) and behavior. ``` GET https://auth.altium.com/connect/authorize ?client_id= &response_type=code &scope=openid+profile+offline_access &redirect_uri=https%3A%2F%2Fyour-app.example.com%2Foauth%2Fcallback &code_challenge= &code_challenge_method=S256 &state= &selectWorkspace=optional ``` After sign-in and consent, Altium Identity redirects to your callback: ``` GET https://your-app.example.com/oauth/callback?code=&state= ``` If the user declines consent, the callback carries an error instead: ``` GET https://your-app.example.com/oauth/callback?error=consent_required&state=&session_state= ``` ## Step 2 — Exchange the code for a global access token This guide authenticates as a **confidential client** using `Authorization: Basic `. **Public clients** (desktop/native/SPA) have no secret — they omit the Basic header and authenticate with `client_id` plus the PKCE `code_verifier` instead. See [Authenticate a desktop application](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md). ``` POST https://auth.altium.com/connect/token Authorization: Basic Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code= &code_verifier= &redirect_uri=https%3A%2F%2Fyour-app.example.com%2Foauth%2Fcallback ``` cURL: ```bash curl -X POST https://auth.altium.com/connect/token \ -u ":" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=" \ -d "code_verifier=" \ --data-urlencode "redirect_uri=https://your-app.example.com/oauth/callback" ``` Response: ```json { "id_token": "...", "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "scope": "openid profile" } ``` The `access_token` is your **global access token**. ### Read the user's profile (optional) The `id_token` already carries identity claims. For up-to-date claims, call the userinfo endpoint with the access token: ``` GET https://auth.altium.com/connect/userinfo Authorization: Bearer ``` cURL: ```bash curl https://auth.altium.com/connect/userinfo \ -H "Authorization: Bearer " ``` Response (a JSON object of claims; the exact set depends on the granted scopes and the user's profile): ```json { "sub": "00000000-0000-0000-0000-000000000000", "username": "jane.engineer@example.com", "email": "jane.engineer@example.com", "given_name": "Jane", "family_name": "Engineer", "organization_id": "11111111-1111-1111-1111-111111111111", "picture": "https://ids.api.altium.com/UserPic/?picName=cus_&size=px128x128" } ``` `sub` is the stable user identifier — the same value that appears in the `id_token`. ## Step 3 — Discover the user's workspaces Query the Altium 365 API with the global access token to list workspaces, then let the user choose one. Include `location.name` — it tells you whether a workspace lives in GovCloud, which decides *where* you exchange the token in Step 4. ```graphql query { desWorkspaceInfos { authId name url location { name } } } ``` A GovCloud workspace reports `location.name = "US GovCloud"`: ```json { "authId": "110ee681-0f50-48e4-a1d1-0e7466ab8682", "name": "OnShape GOV", "url": "https://altium-inc-8685.365-gov.altium.com/", "location": { "name": "US GovCloud" } } ``` Use the chosen workspace's `authId` as `{workspaceId}` in the next step, and its `location.name` to pick the exchange endpoint. Checking for a GovCloud `location.name` is the current recommended way to tell Gov workspaces apart. See the [API examples](https://www.altium.com/documentation/altium-developer-center/altium-365/api/examples). ## Step 4 — Exchange for a workspace access token Exchange the global access token for a token scoped to the chosen workspace, **at the endpoint that matches the workspace's `location`** (Step 3). Requesting `offline_access` also returns a refresh token. In both variants `subject_token` is the same global access token from Step 2. **Commercial workspace — exchange on `auth.altium.com`:** ``` POST https://auth.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &scope=openid offline_access a365:workspace: &subject_token_type=urn:ietf:params:oauth:token-type:access_token &subject_token= &client_id= &client_secret= ``` **GovCloud workspace (`location.name = "US GovCloud"`) — exchange on `auth.365-gov.altium.com` and add `secure=1`:** ``` POST https://auth.365-gov.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &scope=openid offline_access a365:workspace: &subject_token_type=urn:ietf:params:oauth:token-type:access_token &subject_token= &secure=1 &client_id= &client_secret= ``` Presenting your Commercial global token to the Gov `/token` endpoint with `secure=1` mints a Gov workspace token — one whose `iss` is `https://auth.365-gov.altium.com` and which carries a `secure` claim. Omitting `secure=1` on the Gov endpoint — or exchanging a Gov workspace on `auth.altium.com` — returns `access_denied`. See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md). cURL (Commercial workspace; for a Gov workspace, target `https://auth.365-gov.altium.com` and add `--data-urlencode "secure=1"`): ```bash curl -X POST https://auth.altium.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ --data-urlencode "scope=openid offline_access a365:workspace:" \ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ --data-urlencode "subject_token=" \ --data-urlencode "client_id=" \ --data-urlencode "client_secret=" ``` Response (both variants): ```json { "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "refresh_token": "...", "scope": "a365:workspace: offline_access openid" } ``` The `access_token` is your **workspace access token**. Use it to call the Altium 365 API for that workspace: ``` Authorization: Bearer ``` If you already know the workspace at sign-in time, see [Getting a workspace-scoped token directly](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md#getting-a-workspace-scoped-token-directly) in Step 1 to skip Steps 3–4 above. ## Refresh the workspace access token When the workspace access token expires, use the refresh token to get a new one. **Refresh at the endpoint that issued the token:** a Commercial workspace token refreshes on `auth.altium.com`; a GovCloud workspace token refreshes on `auth.365-gov.altium.com` **with `secure=1`**. ``` POST https://auth.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token= &client_id= &client_secret= ``` Response: ```json { "id_token": "...", "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "refresh_token": "...", "scope": "a365:workspace: offline_access openid" } ``` ## Revoke a refresh token **Only refresh tokens can be revoked.** Access tokens are self-contained (JWT) and are validated by signature rather than looked up per request, so they **cannot be revoked** — an issued access token stays valid until it expires. To cut a user off, revoke their **refresh token** (which stops it minting new access tokens) and discard the current access token; keep access-token lifetimes short. POST the refresh token to the revocation endpoint, authenticating with your client credentials: ``` POST https://auth.altium.com/connect/revocation Authorization: Basic Content-Type: application/x-www-form-urlencoded token= &token_type_hint=refresh_token ``` cURL: ```bash curl -X POST https://auth.altium.com/connect/revocation \ -u ":" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token=" \ -d "token_type_hint=refresh_token" ``` - `token` — the refresh token to revoke. - `token_type_hint` — `refresh_token`. A successful request returns `200 OK` with an empty body. Per the revocation standard, the endpoint also returns `200` for an unknown or already-invalid token, so a success response does not confirm the token previously existed. ## Related - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) · [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application.md) - [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) · [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md) --- # Authenticate a desktop application Desktop applications usually cannot host a public HTTPS redirect endpoint, yet OAuth requires the authorization response to arrive at a registered callback. The **ActionWait** pattern solves this: the user still signs in through the browser, the browser is redirected to an **Altium-hosted** callback page, and your application picks up the result over an HTTP long-polling channel instead of hosting a redirect itself. Desktop applications are typically **[public clients](https://oauth.net/2/client-types/)** — they cannot keep a client secret, so they authenticate with **[PKCE](https://oauth.net/2/pkce/)** and never send a `client_secret`. Every token request in this guide carries `client_id` (and, for the code exchange, the PKCE `code_verifier`) and **no** `Authorization: Basic` header. See [Key terms](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#key-terms) for token vocabulary and [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application.md) for details on registering a new application. ## Flow at a glance ```mermaid sequenceDiagram participant App as Desktop app participant Browser participant Identity as Altium Identity participant AW as ActionWait service App->>App: Generate PKCE verifier/challenge + a wait token App->>AW: 1️⃣ POST /await { token } (long-poll starts first) App->>Browser: 1️⃣ Open authorize URL — PKCE challenge, state = ,
redirect_uri = https://auth.altium.com/api/AuthComplete Browser->>Identity: User signs in + consents Identity->>Browser: Redirect to AuthComplete?code=…&state= Note over Browser,AW: Altium delivers the result to ActionWait (internal) AW-->>App: 200 { data: { code, state } } App->>App: Verify state == wait token (CSRF) App->>Identity: 2️⃣ Exchange code + PKCE verifier + client_id (no secret) Identity-->>App: Global access token App->>Identity: 3️⃣ desWorkspaceInfos (global token) App->>Identity: 4️⃣ Token exchange (a365:workspace:{id}) Identity-->>App: Workspace access token + (optional) refresh token ``` After the ActionWait step delivers the authorization code, the flow follows the same logical steps as a web application — exchange the code, discover workspaces, exchange for a workspace token, refresh — **but as a public client**. Those steps are reproduced in full below so this guide stands on its own. ## Step 1 — Start the ActionWait long-poll, then open the browser Generate a PKCE `code_verifier` (a high-entropy random string), derive the `code_challenge` as the base64url-encoded SHA-256 of the verifier, and generate a single high-entropy **wait token** (a UUID works well). **The linchpin: one value is both `state` and the wait token.** Your app uses that wait token in two places at once: - as the OAuth **`state`** parameter in the authorize request, and - as the ActionWait **wait token** it long-polls with. That shared value is what correlates the browser redirect back to your waiting app: the Altium-hosted callback receives the `code` and `state` from the browser and hands them to the ActionWait service, which routes them to the poll whose token equals that `state`. Because only your app and Altium Identity know this value, verifying that the returned `state` matches the token you generated also serves as **CSRF protection**. **Start the long-poll first.** POST your wait token to the ActionWait service *before* opening the browser, so a fast sign-in cannot deliver the result before you are listening for it: ``` POST https://actionwait.altium.com/await Content-Type: application/json { "token": "" } ``` cURL: ```bash curl -X POST https://actionwait.altium.com/await \ -H "Content-Type: application/json" \ -d '{"token":""}' ``` The server holds this request open until the result arrives or the poll's hold interval elapses; if the interval elapses first it returns `408` — this is normal, and you immediately reconnect with the **same** token. Treat the hold interval as an implementation detail: don't assume a fixed duration, just reconnect on `408`. See [ActionWait service API](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md#actionwait-service-api) below for the full protocol. **Then open the browser** at the authorize URL, using the same wait token as `state` and the Altium-hosted `AuthComplete` callback as `redirect_uri`: ``` GET https://auth.altium.com/connect/authorize ?client_id= &response_type=code &scope=openid+profile &redirect_uri=https%3A%2F%2Fauth.altium.com%2Fapi%2FAuthComplete &code_challenge= &code_challenge_method=S256 &state= ``` - `redirect_uri` **must** be the fixed Altium-hosted callback `https://auth.altium.com/api/AuthComplete` — you do not register your own. - `state` **must** be the identical value you long-poll with as the wait token. When the poll returns `200 { data: { code, state } }`, verify `data.state` equals your wait token, then continue to Step 2 with `data.code`. ### Getting a workspace-scoped token directly If you already know the workspace the user should land on, request it up front: include `a365:workspace:` in the `scope` parameter above, and the code exchange in Step 2 returns the workspace token (and refresh token, with `offline_access`) directly, skipping Steps 3–4 below. If you don't know the workspace ahead of time but still want the user to land on one during sign-in, add the optional `selectWorkspace` parameter to the authorize request instead, to have the code exchange return a workspace-scoped token directly. See [Login-into-workspace mode](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#login-into-workspace-mode) for the values (`none` / `strict` / `optional`) and behavior. ``` GET https://auth.altium.com/connect/authorize ?client_id= &response_type=code &scope=openid+profile+offline_access &redirect_uri=https%3A%2F%2Fauth.altium.com%2Fapi%2FAuthComplete &code_challenge= &code_challenge_method=S256 &state= &selectWorkspace=optional ``` ## Step 2 — Exchange the code for a global access token As a **public client** you authenticate with `client_id` and the PKCE `code_verifier` — there is **no** `client_secret` and **no** `Authorization: Basic` header. ``` POST https://auth.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code= &code_verifier= &redirect_uri=https%3A%2F%2Fauth.altium.com%2Fapi%2FAuthComplete &client_id= ``` cURL: ```bash curl -X POST https://auth.altium.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=" \ -d "code_verifier=" \ -d "client_id=" \ --data-urlencode "redirect_uri=https://auth.altium.com/api/AuthComplete" ``` `redirect_uri` must be the same Altium-hosted callback you used in the authorize request (`https://auth.altium.com/api/AuthComplete`), and must match exactly. Response: ```json { "id_token": "...", "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "scope": "openid profile" } ``` The `access_token` is your **global access token**. ## Step 3 — Discover the user's workspaces Query the Altium 365 API with the global access token to list workspaces, then let the user choose one. Include `location.name` — it tells you whether a workspace lives in GovCloud, which decides *where* you exchange the token in Step 4. ```graphql query { desWorkspaceInfos { authId name url location { name } } } ``` A GovCloud workspace reports `location.name = "US GovCloud"`: ```json { "authId": "110ee681-0f50-48e4-a1d1-0e7466ab8682", "name": "Example GovCloud workspace", "url": "https://example-workspace.365-gov.altium.com/", "location": { "name": "US GovCloud" } } ``` Use the chosen workspace's `authId` as `{workspaceId}` in the next step, and its `location.name` to pick the exchange endpoint. Checking for a GovCloud `location.name` is the current recommended way to tell Gov workspaces apart. See the [API examples](https://www.altium.com/documentation/altium-developer-center/altium-365/api/examples). ## Step 4 — Exchange for a workspace access token Exchange the global access token for a token scoped to the chosen workspace, **at the endpoint that matches the workspace's `location`** (Step 3). Requesting `offline_access` also returns a refresh token. In both variants `subject_token` is the same global access token from Step 2, and you send `client_id` with **no** `client_secret`. **Commercial workspace — exchange on `auth.altium.com`:** ``` POST https://auth.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &scope=openid offline_access a365:workspace: &subject_token_type=urn:ietf:params:oauth:token-type:access_token &subject_token= &client_id= ``` **GovCloud workspace (`location.name = "US GovCloud"`) — exchange on `auth.365-gov.altium.com` and add `secure=1`:** ``` POST https://auth.365-gov.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &scope=openid offline_access a365:workspace: &subject_token_type=urn:ietf:params:oauth:token-type:access_token &subject_token= &secure=1 &client_id= ``` Everything up to this point — the browser sign-in, the `AuthComplete` redirect, the ActionWait poll host, and the code exchange — runs on Commercial `auth.altium.com` and yields a global access token. Presenting that Commercial global token to the Gov `/token` endpoint with `secure=1` mints a Gov workspace token — one whose `iss` is `https://auth.365-gov.altium.com` and which carries a `secure` claim. Omitting `secure=1` on the Gov endpoint — or exchanging a Gov workspace on `auth.altium.com` — returns `access_denied`. See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md). cURL (Commercial workspace; for a Gov workspace, target `https://auth.365-gov.altium.com` and add `--data-urlencode "secure=1"`): ```bash curl -X POST https://auth.altium.com/connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ --data-urlencode "scope=openid offline_access a365:workspace:" \ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ --data-urlencode "subject_token=" \ --data-urlencode "client_id=" ``` Response (both variants): ```json { "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "refresh_token": "...", "scope": "a365:workspace: offline_access openid" } ``` The `access_token` is your **workspace access token**. Use it to call the Altium 365 API for that workspace: ``` Authorization: Bearer ``` If you already know the workspace at sign-in time, see [Getting a workspace-scoped token directly](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md#getting-a-workspace-scoped-token-directly) in Step 1 to skip Steps 3–4 above. ## Refresh the workspace access token When the workspace access token expires, use the refresh token to get a new one. **Refresh at the endpoint that issued the token:** a Commercial workspace token refreshes on `auth.altium.com`; a GovCloud workspace token refreshes on `auth.365-gov.altium.com` **with `secure=1`**. As a public client you send `client_id` with no secret. ``` POST https://auth.altium.com/connect/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token= &client_id= ``` Response: ```json { "id_token": "...", "access_token": "...", "expires_in": 14400, "token_type": "Bearer", "refresh_token": "...", "scope": "a365:workspace: offline_access openid" } ``` ## Revoke a refresh token **Only refresh tokens can be revoked.** Access tokens are self-contained (JWT) and are validated by signature rather than looked up per request, so they **cannot be revoked** — an issued access token stays valid until it expires. To cut a user off, revoke their **refresh token** (which stops it minting new access tokens) and discard the current access token; keep access-token lifetimes short. POST the refresh token to the revocation endpoint. As a public client you authenticate with `client_id` in the body — no `Authorization: Basic` header. ``` POST https://auth.altium.com/connect/revocation Content-Type: application/x-www-form-urlencoded token= &token_type_hint=refresh_token &client_id= ``` cURL: ```bash curl -X POST https://auth.altium.com/connect/revocation \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token=" \ -d "token_type_hint=refresh_token" \ -d "client_id=" ``` - `token` — the refresh token to revoke. - `token_type_hint` — `refresh_token`. A successful request returns `200 OK` with an empty body. Per the revocation standard, the endpoint also returns `200` for an unknown or already-invalid token, so a success response does not confirm the token previously existed. ## ActionWait service API The ActionWait service is a **separate host** from Altium Identity: `https://actionwait.altium.com`. It has no Gov-specific host — Gov sign-in polls the same Commercial host. **AES (on-prem)** installations host their own ActionWait service at `{origin}/actionwait` instead — see [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md). **`POST /await`** — long-polls for the authorization result tied to your wait token. Request (`Content-Type: application/json`): ```json { "token": "" } ``` Response on success (`200`): ```json { "data": { "code": "", "state": "" } } ``` - `data.code` — the OAuth authorization code; exchange it at the token endpoint (Step 2). - `data.state` — echoes your wait token; **verify it matches** before exchanging the code. Status codes: - `200` — the result is ready (body as above). - `408` — the poll's hold interval elapsed with no result yet. **This is normal, not a failure** — immediately reconnect by POSTing `/await` again with the **same** token. Repeat until you get `200`, hit `410`, or your own overall timeout elapses. - `410` — the wait token is no longer valid: it was already consumed, or a newer `/await` for the same token superseded this connection. Treat the attempt as over — start a fresh sign-in from Step 1 with a **new** wait token. **Two distinct timeouts.** The server bounds how long it holds each `/await` request open before returning `408`; treat that hold interval as an implementation detail that may change, and simply reconnect whenever you receive a `408`. It is *not* how long a user has to sign in. Your app owns a separate **overall sign-in timeout** that spans the whole reconnect loop (for example, the TypeScript library defaults to 3 minutes and the .NET sign-in tool uses 5). When your overall timeout elapses, stop reconnecting and abandon the wait token. Mind your **HTTP client's own request timeout** as well: the hold interval can exceed it (.NET's `HttpClient` defaults to 100 seconds). A per-request timeout that fires mid-hold says nothing about the sign-in, so treat it exactly like a `408` and reconnect — never as a sign-in failure. Simplest is to disable the per-request timeout entirely and let your overall sign-in timeout be the only deadline. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | `/await` returns `408` repeatedly | Normal long-poll cycling while the user is still signing in | Keep reconnecting with the same token; only give up after your own overall timeout | | `/await` returns `410` | Token already consumed, or a second poll superseded this one | Start a new sign-in from Step 1 with a fresh token | | Browser completes but app never returns | The `state` in the authorize request didn't match the `token` you poll with | Use the identical value for `state` and the wait `token` | | Sign-in fails with a client-side request timeout while the user is still on the login page | The HTTP client's per-request timeout is shorter than the server's hold interval | Reconnect on a client-side request timeout as you would on a `408`, but fail on other transport errors such as DNS or TLS failures; better still, disable the per-request timeout and bound the flow with your overall sign-in timeout | | `State mismatch` after `200` | Returned `data.state` ≠ your wait token (possible CSRF) | Abort the sign-in; do not exchange the code | | `invalid_grant` at token endpoint | Code expired/already used, or `redirect_uri` ≠ `https://auth.altium.com/api/AuthComplete` | Restart sign-in; send the exact redirect URI and PKCE verifier | ## Related - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) · [Web and server application flow](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) - [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) · [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md) --- # GovCloud considerations Altium GovCloud is an isolated environment for ITAR/regulated workspaces. The authentication flows are the same as the [web](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) and [desktop](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) guides, with the differences outlined below. Contact Altium for GovCloud onboarding. ## Separate issuer GovCloud uses its own issuer and endpoints under `auth.365-gov.altium.com` (distinct from Commercial `auth.altium.com`). A token's issuer (`iss`) determines whether it is a Commercial or a Gov token. **Commercial and Gov are kept strictly separate:** a Gov token must never be used against Commercial services, and a Commercial token must never be used against GovCloud. **Gov hosts for authorize/token only.** Only `/connect/authorize` and `/connect/token` move to the gov host. The ActionWait poll service (`/await`) has no gov-specific DNS: | Environment | Authorize / token | ActionWait poll | | --- | --- | --- | | Prod Gov | `auth.365-gov.altium.com` | `actionwait.altium.com/await` | ## The two-token model Because a single self-contained JWT can be replayed wherever it travels, GovCloud restores a hard boundary using **two tokens**: - A **global token** — used for requests to global services (for example, user profile or workspace info). - A **secure token** — carries a `secure=1` attribute and is bound to GovCloud. Only GovCloud authorizes with it; Commercial workspaces reject it. This guarantees a GovCloud (`secure=1`) token cannot be accepted by Commercial services. ## Identifying GovCloud workspaces When you discover workspaces (`desWorkspaceInfos`), a GovCloud workspace reports `location.name = "US GovCloud"`. Use that to decide which `/token` endpoint to exchange on. Checking `location.name` is the current recommended way to tell Gov workspaces apart. ## Exchanging tokens for GovCloud workspaces Following the [recommended flow](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#the-authentication-journey), you sign in once on Commercial `auth.altium.com` and then exchange the global token per workspace. - **Commercial workspace**: exchange your Commercial global token at `auth.altium.com/connect/token` **without** `secure=1` to get a Commercial workspace token (`iss = auth.altium.com`). Requesting a Gov workspace here returns `access_denied`. - **GovCloud workspace**: exchange your Commercial global token at `auth.365-gov.altium.com/connect/token` **with** `secure=1` to get a GovCloud workspace token (`iss = auth.365-gov.altium.com`). Requesting a Commercial workspace here returns `access_denied`. Refresh each workspace token at the endpoint that issued it (a Gov workspace token refreshes on `auth.365-gov.altium.com` with `secure=1`). **Rule of thumb:** send `secure=1` on token requests to the **Gov** `/token` endpoint and omit it on the **Commercial** `/token` endpoint — regardless of which side issued the token you present. `secure=1` is a token-endpoint parameter; it is not sent on `/authorize`. ## What this means for your application Hold the **Commercial global token** from sign-in for global services, and use each **Gov workspace token** only for that workspace's GovCloud API calls. Never send a Gov token to global (Commercial) services, and never send a Commercial token to GovCloud. ## Related - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) - [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims.md) (the `secure` claim) - [Web and server application flow](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) - [Desktop application flow](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) --- # AES (on-prem) considerations Altium Enterprise Server (AES) is a customer-hosted, on-prem installation. The authentication flows are the same as the [web](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) and [desktop](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) guides, with the differences outlined below. ## Its own environment, on a customer domain Unlike Commercial Cloud (`auth.altium.com`) and GovCloud (`auth.365-gov.altium.com`), which are fixed Altium-hosted domains, an AES installation runs on a **customer-controlled origin** — there is no fixed hostname. Given the AES server's origin (e.g. `https://aes.server.example:9785`), the base URL is that origin plus `/unifiedlogin`: | Purpose | URL | | ----------------------- | ----------------------------------------- | | Authorize | `{origin}/unifiedlogin/connect/authorize` | | Token | `{origin}/unifiedlogin/connect/token` | | ActionWait poll | `{origin}/actionwait/await` | | `AuthComplete` callback | `{origin}/unifiedlogin/api/AuthComplete` | | Scope Introspection | `{origin}/unifiedlogin/api/ClientScopes` | **AES hosts its own ActionWait and callback** — unlike Gov, which has no ActionWait/callback of its own and shares Commercial's. Every endpoint for an AES installation lives on the same customer origin. Because the origin varies per installation, each library exposes a builder instead of a fixed constant: ```ts import { createAesEndpoints } from "@altium-developer/altium-auth"; const endpoints = createAesEndpoints("https://aes.server.example:9785"); ``` ```csharp using Altium.Auth; var endpoints = AltiumEndpoints.Aes("https://aes.server.example:9785"); ``` ```python from altium_auth import aes_endpoints endpoints = aes_endpoints("https://aes.server.example:9785") ``` ## Single workspace AES hosts a single workspace, so its ID is known up front and there is nothing to choose: the **sign-in request should include the scope `a365:workspace:{workspaceId}`** and the issued access token is already workspace-scoped — one trip, no token exchange. If the workspace ID is not already known, the client application can introspect the exact value of that scope by making a GET request to `{origin}/unifiedlogin/api/ClientScopes?clientId={clientId}` on the AES installation. That endpoint is not AES-specific — Commercial and GovCloud expose it too, at `{base}/api/ClientScopes` — but only on AES does it return an `a365:workspace:{workspaceId}` scope. A Cloud client may reach many workspaces, and none of them follow from its client ID, so on Cloud the response carries only the client's static scopes and you discover workspaces with `desWorkspaceInfos` instead. The [workspace token exchange](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) is technically available on AES (it behaves as on Commercial Cloud, without `secure=1`), but it buys nothing here — it exists for the Cloud case where the user picks a workspace after signing in. There is no `selectWorkspace` prompt on AES either: with one workspace, requesting its scope at sign-in is logging into the workspace. AES is its own environment. A token issued by an AES installation is only accepted by that same AES installation — it cannot be exchanged for a Commercial or GovCloud workspace token, and a Commercial/Gov token cannot be exchanged at an AES endpoint. There is no equivalent of the Commercial→Gov workspace-bridging flow for AES. ## Miscellaneous AES follows the **same rule as Commercial Cloud**: token requests never carry `secure=1`, and AES-issued tokens carry no `secure` claim. There is no `workspaceId` claim on the workspace token, and no `selectWorkspace` prompt for AES. ## Related - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) · [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims.md) - [Web and server application flow](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) · [Desktop application flow](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) --- # Access token claims Altium 365 access tokens are signed JWTs (header `typ: at+jwt`, `alg: RS256`). You can base64url-decode the payload to see who the token is for, which application obtained it, what it grants, and which cloud it belongs to. > **Validate before you trust.** Verify the token's signature against the issuer's JWKS (published in the [discovery document](https://altiumdeveloper.github.io/altium-auth/guides/overview.md#endpoints)) and check `exp`/`iss`/`aud` before relying on any claim. Decoding alone does not prove authenticity. Example payload (a GovCloud workspace token; identifiers shown as placeholders): ```json { "iss": "https://auth.365-gov.altium.com", "sub": "00000000-0000-0000-0000-000000000000", "client_id": "11111111-1111-1111-1111-111111111111", "scope": ["a365:workspace:22222222-2222-2222-2222-222222222222", "openid", "profile", "offline_access"], "workspaceId": "22222222-2222-2222-2222-222222222222", "secure": "1", "iat": 1783517196, "exp": 1783531596 } ``` ## The claims that matter | Claim | Present on | Meaning | | --- | --- | --- | | `iss` | every token | **Issuer** — the base URL that minted the token, which identifies the cloud: `https://auth.altium.com` (Commercial), `https://auth.365-gov.altium.com` (GovCloud), or `{origin}/unifiedlogin` (AES, on-prem). | | `sub` | every token | **Subject** — for user tokens, the user's ID (a stable identifier; the same value the [`userinfo`](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md#read-the-users-profile-optional) endpoint returns as `sub`). | | `client_id` | every token | The **application** — the registered OAuth client that obtained the token. | | `scope` | every token | The granted scopes. A workspace token includes `a365:workspace:{workspaceId}`; `offline_access` means a refresh token was issued alongside it. | | `workspaceId` | workspace tokens | The workspace's `authId` — the single workspace this token grants access to (it matches the `a365:workspace:` scope). **Absent on global tokens, and on all AES tokens**. | | `secure` | GovCloud tokens | `"1"` — marks a GovCloud token bound to GovCloud. **Absent on Commercial and AES tokens.** | ## Reading the token kind from the claims - **Global vs. workspace token** — a workspace token has a `workspaceId` claim and an `a365:workspace:{id}` scope; a global token has neither. - **Commercial vs. GovCloud token** — a GovCloud token carries `secure: "1"` and a Gov `iss`; a Commercial token has no `secure` claim. See [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md). - **AES (on-prem) token** — a workspace token has a `a365:workspace:{id}` scope, but there is **no `workspaceId` claim** and **no `secure` claim** (same as Commercial). `iss` is the AES installation's own `{origin}/unifiedlogin` base. See [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md). ## Standard claims Tokens also carry the usual OAuth 2.0 / OIDC JWT claims — `iat`, `nbf`, `exp` (validity window, epoch seconds), `aud` (audience), `jti` (token ID), `amr` (authentication methods), `sid` (session ID), and `idp` (identity provider). Treat any claim not listed above as an implementation detail that may change. ## Related - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview.md) · [Web and server application flow](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) - [GovCloud considerations](https://altiumdeveloper.github.io/altium-auth/guides/govcloud.md) · [AES (on-prem) considerations](https://altiumdeveloper.github.io/altium-auth/guides/aes.md) --- # Register your application Application registration is currently a guided process handled together with Altium — there is no self-service registration portal. [Contact Altium](https://www.altium.com/contact) to register your application. ## What you provide - **Client name and description** — shown to users on the consent screen. - **Client type** — *confidential* (web/server apps that can keep a secret) or *public* (desktop, native, or SPA apps that cannot). This determines whether you receive a client secret. - **Redirect URL(s)** — the callback URL(s) where users return after sign-in, for example `https://your-app.example.com/oauth/callback`. Web and server apps register their own redirect URL. Desktop apps do not host one — they use the [ActionWait pattern](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) with the fixed Altium-hosted redirect (`https://auth.altium.com/api/AuthComplete`). - **Requested scopes** — the scopes your application needs (see [OAuth Scopes](https://www.altium.com/documentation/altium-developer-center/altium-365/key-concepts/oauth-scopes)). - **Optional** — a client URL and logo image URL, shown on the consent screen. ## What you receive - **Client ID** — public identifier for your application. - **Client secret** — *confidential clients only* — a credential used to authenticate your application at the token endpoint. Public clients (desktop/native/SPA) are registered **without** a secret and authenticate with PKCE instead. > Keep your **client secret** confidential. Store and transmit it securely; never embed it in a public client or front-end code. If your app cannot guarantee this (a desktop, native, or single-page app), register it as a **public client** and use PKCE — do not ship a secret. ## Next - [Authenticate a web or server application](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps.md) - [Authenticate a desktop application](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps.md) --- # @altium-developer/altium-auth [![npm version](https://img.shields.io/npm/v/@altium-developer/altium-auth.svg)](https://www.npmjs.com/package/@altium-developer/altium-auth) [![npm downloads](https://img.shields.io/npm/dm/@altium-developer/altium-auth.svg)](https://www.npmjs.com/package/@altium-developer/altium-auth) [![node](https://img.shields.io/node/v/@altium-developer/altium-auth.svg)](https://www.npmjs.com/package/@altium-developer/altium-auth) [![CI](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/typescript-ci.yml/badge.svg)](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/typescript-ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/typescript/LICENSE) Altium 365 OAuth2 / OpenID Connect authentication library. Supports both client types, and Commercial Cloud, GovCloud, and AES (on-prem) deployments: - **Public clients** (desktop, on-prem, native) — browser sign-in with PKCE over Altium's **ActionWait** long-poll: signIn. - **Confidential clients** (web/server backends with a secret) — the standard **authorization-code redirect** flow via composable steps: createAuthorizationUrl + exchangeCode. - **Workspace tokens**, **token refresh**, and first-class **GovCloud** and **AES** (on-prem) support. **Zero runtime dependencies.** Runs on Node ≥22, Bun, and Deno (and bundled apps that polyfill Node's `crypto`). ## Documentation The library implements the flow described in these guides (protocol-level, independent of this package) — start here if you're new to Altium Identity: - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview/) — endpoints, key terms, and the recommended flow - [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application/) — client types, redirect URLs, credentials - [Web / server apps](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps/) — authorization-code redirect flow (confidential clients) - [Desktop apps](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps/) — the ActionWait pattern (public clients) - [GovCloud](https://altiumdeveloper.github.io/altium-auth/guides/govcloud/) — Commercial vs Gov and the `secure=1` two-token model - [AES (on-prem)](https://altiumdeveloper.github.io/altium-auth/guides/aes/) — customer-hosted installations - [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims/) — what's inside a token (`iss`, `workspaceId`, `secure`, scopes) ## Installation ```bash npm install @altium-developer/altium-auth ``` ## Quick Start Only `clientId` and `scopes` are required — the endpoints default to the Altium 365 Commercial Cloud. Pick the flow that matches your app. ### Public apps (desktop — ActionWait sign-in) For desktop clients that **can't host a public redirect**. `signIn` opens the browser, waits for the callback over Altium's ActionWait long-poll, and returns tokens. See [Desktop apps](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps/). ```typescript import { signIn, signIntoWorkspace } from "@altium-developer/altium-auth"; const config = { clientId: "your-client-id", scopes: "openid profile", }; // Opens a browser login page and waits for the callback const tokens = await signIn(config); // Persist `tokens` yourself — e.g. VS Code SecretStorage, a keychain, or a file. // Exchange the global token for a workspace-scoped token const workspaceToken = await signIntoWorkspace(config, tokens.access_token, "workspace-id-here"); ``` The library returns tokens and never stores them — persistence, refresh, and sign-out are entirely yours to manage. If your host runtime needs to open URLs through a host API, keep using `signIn` and provide a browser opener. The library still owns PKCE, ActionWait polling, state correlation, CSRF validation, and token exchange: ```typescript const tokens = await signIn(config, { openBrowser: async (url) => { // Example: VS Code extension host, Electron shell, native app bridge, etc. await openExternalUrl(url); }, }); ``` ### Confidential apps (web / server — authorization-code redirect) For web/server backends that **host their own redirect endpoint**. Set `clientSecret` on the config to authenticate as a confidential client (HTTP Basic), and drive the flow with two composable steps. See [Web / server apps](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps/). ```typescript import { createAuthorizationUrl, exchangeCode } from "@altium-developer/altium-auth"; const config = { clientId: "your-client-id", clientSecret: "your-client-secret", // confidential client → HTTP Basic auth scopes: "openid profile offline_access", }; const redirectUri = "https://my-service.example.com/oauth/callback"; // On your login route: build the URL, stash state + verifier, then redirect. app.get("/login", (req, res) => { const { url, state, codeVerifier } = createAuthorizationUrl(config, { redirectUri }); req.session.oauth = { state, codeVerifier }; res.redirect(url); }); // On your callback route: verify state, then exchange the code for tokens. app.get("/oauth/callback", async (req, res) => { const { state, codeVerifier } = req.session.oauth; if (req.query.state !== state) throw new Error("state mismatch"); const tokens = await exchangeCode(config, { code: String(req.query.code), codeVerifier, redirectUri, }); // Persist `tokens`; then discover/exchange workspaces and refresh as usual. }); ``` `createAuthorizationUrl` is synchronous (it generates PKCE + `state` and returns the URL to redirect to); `exchangeCode` performs the token exchange. Both `signIntoWorkspace` and `refreshToken` also send the `clientSecret` automatically when it's set. ### GovCloud Altium GovCloud is an isolated environment for ITAR/regulated workspaces. Use the exported `GOV_CLOUD_ENDPOINTS` — that's it. The library detects the Gov token endpoint and adds the required `secure=1` to token requests automatically (the two-token model); no `secure` flag to set. ```typescript import { signIn, GOV_CLOUD_ENDPOINTS } from "@altium-developer/altium-auth"; const tokens = await signIn({ clientId: "your-gov-client-id", scopes: "openid profile", ...GOV_CLOUD_ENDPOINTS, }); ``` 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 — Gov endpoint → sent, Commercial endpoint → omitted — so pointing `tokenEndpoint` at the Gov host is all it takes to exchange a Commercial token for a Gov workspace token. > Gov tokens must never be used against Commercial services, and vice versa. See the [GovCloud](https://altiumdeveloper.github.io/altium-auth/guides/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 `createAesEndpoints()` 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. ```typescript import { signIn, createAesEndpoints, getClientScopes } from "@altium-developer/altium-auth"; const clientId = "your-aes-client-id"; const endpoints = createAesEndpoints("https://aes.server.example:9785"); const scopes = await getClientScopes(endpoints.scopeEndpoint, clientId); const tokens = await signIn({ clientId, scopes: scopes.join(" "), ...endpoints, }); ``` See the [AES (on-prem)](https://altiumdeveloper.github.io/altium-auth/guides/aes/) guide. ### Custom Deployments For other custom deployments, override any of the four endpoints. Anything you omit still falls back to the Commercial Cloud: ```typescript import { COMMERCIAL_CLOUD_ENDPOINTS } from "@altium-developer/altium-auth"; const config = { clientId: "your-client-id", scopes: "openid profile", // Point auth + token at a custom deployment; keep the rest on the Commercial Cloud: authEndpoint: "https://auth.my-custom-deployment.example/connect/authorize", tokenEndpoint: "https://auth.my-custom-deployment.example/connect/token", }; // Endpoint presets are exported to inspect or spread: // COMMERCIAL_CLOUD_ENDPOINTS and GOV_CLOUD_ENDPOINTS. console.log(COMMERCIAL_CLOUD_ENDPOINTS.tokenEndpoint); ``` ## API Reference ### `signIn(config, options?)` Performs OAuth2 PKCE sign-in. Opens the browser to the authorization URL, long-polls for the code callback via ActionWait, exchanges it for tokens, and returns the result. **Does NOT** persist tokens or manage auth state — those are your responsibility. You receive a `TokenSet` back and store it yourself. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | OAuth2 configuration (`clientId` + `scopes` required; endpoints default to the Commercial Cloud) | | options | `SignInOptions` | Optional: `timeoutMs` (default 180s), `AbortSignal` for cancellation, `selectWorkspace` for login-into-workspace mode, and `openBrowser` for host-specific URL opening | **Returns:** `Promise` — the full token response from the IdP. ### `createAuthorizationUrl(config, options?)` Builds an OAuth2 authorization URL with PKCE for the redirect-based (authorization-code) flow. Synchronous — no network I/O. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | Same as signIn | | options | `AuthorizationUrlOptions` | Optional: `redirectUri`, `state`, `codeVerifier`, `scopes`, `selectWorkspace` overrides | **Returns:** `AuthorizationRequest` — `{ url, state, codeVerifier }`. Redirect the user to `url`; persist `state` and `codeVerifier` (e.g. in the session) for the callback. ### `exchangeCode(config, params)` Exchanges an authorization `code` (from your redirect callback) for tokens via the `authorization_code` grant. Sends the `clientSecret` (HTTP Basic) for confidential clients, or `client_id` + PKCE `codeVerifier` for public clients. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | Same as signIn | | params | `ExchangeCodeParams` | `{ code, codeVerifier?, redirectUri? }` | **Returns:** `Promise`. **Throws:** `Error` if `code` is empty. ### `signIntoWorkspace(config, baseAccessToken, workspaceAuthId)` Exchanges a base access token (the `access_token` from `signIn`) for a workspace-scoped token using OAuth2's `urn:ietf:params:oauth:grant-type:token-exchange` grant. **Does NOT** cache tokens internally — call it each time you need a fresh token. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | Same as signIn | | baseAccessToken | `string` | The `access_token` returned by `signIn` | | workspaceAuthId | `string` | The workspace's auth ID from Altium 365 | **Returns:** `Promise` — the workspace-scoped token. **Throws:** `Error` if `baseAccessToken` is empty. ### `refreshToken(config, refreshToken)` Exchanges a refresh token for a fresh `TokenSet` using the OAuth2 `refresh_token` grant. Use it when an access token has expired (compare `TokenSet.expires_at` against the current epoch seconds) to avoid a full interactive sign-in. Works the same for **global and workspace** refresh tokens — no scope is sent, so the refreshed token keeps whatever grant the refresh token was issued for (a global token stays global; a workspace token stays workspace-scoped). **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | Same as signIn | | refreshToken | `string` | A `refresh_token` from a prior `signIn` or `signIntoWorkspace` TokenSet | **Returns:** `Promise` — the refreshed tokens. If the IdP rotates refresh tokens, the response includes a new `refresh_token`; persist it and discard the old one. **Throws:** `Error` if `refreshToken` is empty, or if the grant is rejected (e.g. the refresh token expired or was revoked). > **Tip:** request the `offline_access` scope at sign-in to receive a `refresh_token`. ```typescript import { refreshToken } from "@altium-developer/altium-auth"; const nowSeconds = Math.floor(Date.now() / 1000); if (tokens.expires_at && tokens.expires_at <= nowSeconds && tokens.refresh_token) { tokens = await refreshToken(config, tokens.refresh_token); // Persist `tokens` again — including a rotated refresh_token if present. } ``` ### `revokeRefreshToken(config, refreshToken)` Revokes a refresh token via the OAuth2 Token Revocation endpoint (RFC 7009) — call it on sign-out to invalidate the grant server-side. The revocation endpoint is derived from the token endpoint (`/connect/token` → `/connect/revocation`); only **refresh** tokens are revoked. Client authentication follows the client type (confidential → HTTP Basic; public → `client_id` in body). **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | config | `OAuthConfig` | Same as signIn | | refreshToken | `string` | The `refresh_token` to revoke | **Returns:** `Promise`. Per RFC 7009 the call is idempotent (the endpoint returns `200` for both known and unknown tokens). **Throws:** `Error` if `refreshToken` is empty, or the endpoint returns a non-2xx status. After a successful revoke, a subsequent `refreshToken` with the same token fails with `invalid_grant`. ```typescript import { revokeRefreshToken } from "@altium-developer/altium-auth"; // On sign-out: if (tokens.refresh_token) { await revokeRefreshToken(config, tokens.refresh_token); } // ...then discard your local copy of the tokens. ``` ## Types ### `OAuthConfig` ```typescript interface OAuthConfig { clientId: string; // OAuth2 client ID registered with Altium scopes: string; // Space-delimited, must include "openid profile" clientSecret?: string; // Confidential clients only → HTTP Basic auth secure?: boolean; // Override Gov auto-detection (normally unset) // Optional — default to COMMERCIAL_CLOUD_ENDPOINTS. Override individually for // custom installations, spread GOV_CLOUD_ENDPOINTS for GovCloud, or spread // createAesEndpoints(origin) for an AES (on-prem) installation. authEndpoint?: string; // default: https://auth.altium.com/connect/authorize tokenEndpoint?: string; // default: https://auth.altium.com/connect/token actionWaitEndpoint?: string;// default: https://actionwait.altium.com/await scopeEndpoint?: string; redirectUri?: string; // default: https://auth.altium.com/api/AuthComplete } ``` ### `TokenSet` ```typescript interface TokenSet { access_token: string; refresh_token?: string; id_token?: string; token_type?: string; expires_in?: number; expires_at?: number; // Epoch seconds (computed by library) scope?: string; } ``` > `access_token` is a signed JWT — decode it to read `iss`, `workspaceId`, `secure`, and scopes. See [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims/). ### `AuthorizationUrlOptions` / `SignInOptions` Both `createAuthorizationUrl` and `signIn` accept a `selectWorkspace` option for [login-into-workspace mode](https://altiumdeveloper.github.io/altium-auth/guides/overview/#login-into-workspace-mode): ```typescript // In AuthorizationUrlOptions (createAuthorizationUrl) and SignInOptions (signIn): selectWorkspace?: "none" | "strict" | "optional"; // "strict" — workspace selection mandatory; returned token is workspace-scoped. // "optional" — workspace selection offered; user may skip. // "none" or omitted (default) — no workspace prompt; issues a global access token. // SignInOptions only: openBrowser?: (url: string) => void | Promise; ``` Use `openBrowser` when embedding the library into a host with its own browser API such as VS Code, Electron, or a native app bridge. ActionWait polling remains owned by the library. ## Error Handling The library throws descriptive `Error` objects in these scenarios: | Scenario | Error message pattern | |----------|----------------------| | Missing `clientId`/`scopes` | `OAuthConfig.{field} is required and must be non-empty.` | | Invalid endpoint URL | `OAuthConfig.{field} is not a valid URL:` | | ActionWait timeout | `ActionWait poll timed out after {n}ms.` | | Sign-in cancelled | `Sign-in cancelled.` | | CSRF detected | `State mismatch during sign-in (possible CSRF attack).` | | Empty base token for workspace exchange | `baseAccessToken is required — pass the access_token from signIn().` | | Empty refresh token | `refreshToken is required — pass the refresh_token from a prior TokenSet.` | | Empty authorization code | `code is required — pass the authorization code from the redirect callback.` | | Token endpoint returns OAuth error | `Token endpoint {status} {error_code} — {description}` | | ClientScopes lookup fails | `ClientScopes endpoint {status}:` / `ClientScopes endpoint returned an unexpected body (expected a JSON array of strings):` | ## Compatibility - **Node.js ≥22** (`engines` in package.json) — uses the global `fetch`, the global Web `crypto` (`randomUUID`), and the `crypto` module (`randomBytes`, `createHash`). CI tests Node 22, 24 and 26; Node 20 and older are not supported. - **Bun / Deno** — supported through their Node-compatible `fetch` and `crypto`. CI runs the unit tests and the conformance vectors inside the latest Bun and Deno 2.x, and checks that the built package loads in each one. - **Browsers / bundlers** — works where your bundler polyfills Node's `crypto` and `Buffer`. ## Development ```bash npm install # install dev dependencies npm test # run unit tests npm run test:conformance # run the shared conformance vectors npm run test:bun # both suites inside Bun (needs bun on PATH) npm run test:deno # both suites inside Deno (needs deno on PATH) npm run lint # lint src/ npm run build # compile to dist/ ``` ### E2E sign-in test Run the sign-in flow against a live Altium environment. It opens your browser, waits for the ActionWait callback, and prints the resulting tokens (and optionally exercises workspace exchange and refresh). It drives the built package in `dist/`, so `npm run test:e2e` builds first. On Bun or Deno, build, then run the script with the same options: `bun scripts/test-signin.ts …` or `deno run -A scripts/test-signin.ts …`. ```bash # Commercial Cloud, public client npm run test:e2e -- YOUR_CLIENT_ID # GovCloud (Dev) — verifies the secure=1 two-token model npm run test:e2e -- --env dev-gov YOUR_GOV_CLIENT_ID # AES (on-prem) — verifies the origin-derived endpoints, no secure=1 npm run test:e2e -- --env aes --aes-origin https://aes.server.example:9785 YOUR_AES_CLIENT_ID # Also exchange a workspace token and exercise refresh npm run test:e2e -- --workspace --refresh YOUR_CLIENT_ID # Confidential client (HTTP Basic) — pass the secret via env, never on the CLI A365_CLIENT_SECRET=... npm run test:e2e -- YOUR_CLIENT_ID ``` Options include `--env prod|dev|gov|dev-gov|aes`, `--aes-origin` (required for `--env aes`), `--workspace-env`, `--secure`/`--no-secure`, `--scopes`, `--workspace`, `--refresh`, `--userinfo`, `--revoke`, plus `--authorize-url`/`--exchange-code`/`--redirect-uri` for confidential (custom-callback) clients — see the header of [`scripts/test-signin.ts`](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/typescript/scripts/test-signin.ts). The client secret is read from `A365_CLIENT_SECRET` so it never appears in shell history or the process list. See [CONTRIBUTING.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/CONTRIBUTING.md) and [AGENTS.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/AGENTS.md) for the full contributor guide. ## Security Please report vulnerabilities privately — see [SECURITY.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/SECURITY.md). ## License [MIT](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/typescript/LICENSE) © Altium Limited --- # Altium.Auth [![NuGet](https://img.shields.io/nuget/v/Altium.Auth?label=nuget)](https://www.nuget.org/packages/Altium.Auth) [![NuGet downloads](https://img.shields.io/nuget/dt/Altium.Auth.svg)](https://www.nuget.org/packages/Altium.Auth) [![CI](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/dotnet-ci.yml/badge.svg)](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/dotnet-ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/AltiumDeveloper/altium-auth/blob/main/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](https://github.com/AltiumDeveloper/altium-auth/blob/main/spec/conformance/vectors.json) 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): - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview/) — endpoints, key terms, recommended flow - [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application/) — client types, redirect URLs, credentials - [Web / server apps](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps/) — authorization-code redirect flow (confidential) - [Desktop apps](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps/) — the ActionWait pattern (public) - [GovCloud](https://altiumdeveloper.github.io/altium-auth/guides/govcloud/) — Commercial vs Gov and the `secure=1` two-token model - [AES (on-prem)](https://altiumdeveloper.github.io/altium-auth/guides/aes/) — customer-hosted installations - [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims/) — what's inside a token (`iss`, `workspaceId`, `secure`, scopes) ## 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. ```csharp using Altium.Auth; using System.Diagnostics; // ActionWait long-polls: the CancellationToken below is the real deadline. var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; 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. using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); TokenSet tokens = await client.SignInAsync(cts.Token); // 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`: ```csharp // 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](https://altiumdeveloper.github.io/altium-auth/guides/overview/#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. ```csharp 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. ```csharp 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](https://altiumdeveloper.github.io/altium-auth/guides/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. ```csharp 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)](https://altiumdeveloper.github.io/altium-auth/guides/aes/) guide. ### Refresh & sign-out ```csharp // 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); ``` ## Use from PowerShell `Altium.Auth` has no dependencies on any target, so PowerShell can load it directly with no SDK, compiler or wrapper module. PowerShell 7+ loads the `net8.0` asset and Windows PowerShell 5.1 loads the `netstandard2.0` one. Download the package from nuget.org and load the asset for your edition. Keep the `.zip` extension: `Expand-Archive` on 5.1 refuses a `.nupkg`. ```powershell $lib = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'netstandard2.0' } else { 'net8.0' } $ver = (Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/altium.auth/index.json').versions[-1] Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/altium.auth/$ver/altium.auth.$ver.nupkg" -OutFile pkg.zip Expand-Archive pkg.zip -DestinationPath pkg -Force Add-Type -Path "./pkg/lib/$lib/Altium.Auth.dll" ``` Then sign in and call the API: ```powershell $http = [System.Net.Http.HttpClient]::new() $http.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $options = [Altium.Auth.AltiumAuthOptions]@{ ClientId = 'your-client-id' Scopes = 'openid profile' OpenBrowser = { param($url) Start-Process $url } } $client = [Altium.Auth.AltiumAuthClient]::new($http, $options) $cts = [System.Threading.CancellationTokenSource]::new([timespan]::FromMinutes(5)) function Wait-Task($task) { try { while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 200 } $task.GetAwaiter().GetResult() } finally { if (-not $task.IsCompleted) { $cts.Cancel() } } } $tokens = Wait-Task $client.SignInAsync([Altium.Auth.WorkspaceSelection]::None, $cts.Token) $body = @{ query = '{ desWorkspaceInfos { authId name url } }' } | ConvertTo-Json Invoke-RestMethod https://eur.365.altium.com/api/graphql -Method Post -ContentType 'application/json' ` -Headers @{ Authorization = "Bearer $($tokens.AccessToken)" } -Body $body ``` The hashtable cast is how PowerShell sets the options' init-only properties, and the ScriptBlock converts to the `Action` that `OpenBrowser` expects. PowerShell has no `await`, so `Wait-Task` polls the task rather than blocking on `GetAwaiter().GetResult()`, which would ignore Ctrl+C until the deadline. When you do press Ctrl+C, its `finally` cancels the sign-in. `$http.Timeout` is cleared because the ActionWait long-poll outlasts `HttpClient`'s 100-second default, which leaves the `CancellationTokenSource` as the only deadline. If a call fails, the real message is on `$_.Exception.InnerException`. There is no client-credentials grant, so a script can't get a token with nobody present. It signs in interactively once and reuses the result. Check `ExpiresAt` before reaching for `offline_access`: lifetimes are set per client and can be long. ### The ready-made script [`tools/powershell/Get-AltiumToken.ps1`](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/dotnet/tools/powershell/Get-AltiumToken.ps1) does all of the above: it downloads and caches the assembly, picks the right asset, signs in and optionally exchanges for a workspace token with `-WorkspaceId`. Windows blocks scripts downloaded from the internet, so unblock it before the first run, or start it with `powershell -ExecutionPolicy Bypass -File .\Get-AltiumToken.ps1`. ```powershell Unblock-File ./Get-AltiumToken.ps1 ./Get-AltiumToken.ps1 -ClientId $id -WorkspaceId $authId ``` ### Keeping tokens between runs The script can also save the token set so later runs skip the browser. With `-TokenFile`, it encrypts the tokens with DPAPI, so only the same Windows user on the same machine can read them, and on the next run it refreshes silently from the saved refresh token. If the refresh is rejected it falls back to an interactive sign-in. None of this is needed for a one-off script, but it helps for anything scheduled. ```powershell ./Get-AltiumToken.ps1 -ClientId $id -Scopes 'openid profile offline_access' -TokenFile ~/.altium-tokens ``` ## 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` ```csharp 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? 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](https://altiumdeveloper.github.io/altium-auth/guides/aes/#single-workspace) — 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` ```csharp 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](https://altiumdeveloper.github.io/altium-auth/guides/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 (cancellation, a TLS/certificate failure, or a CSRF `state` mismatch). A request timeout mid-poll is *not* a failure — like a `408`, the client reconnects. Any other transport failure ends the sign-in. ## 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. - **PowerShell 7+ and Windows PowerShell 5.1** can load and drive the assembly directly — see [Use from PowerShell](https://altiumdeveloper.github.io/altium-auth/libraries/dotnet.md#use-from-powershell). - You provide the `HttpClient`; the client sets headers/bodies but does not own the transport — so its `Timeout` is yours to set (`SignInAsync` long-polls; see the note in [Public apps](https://altiumdeveloper.github.io/altium-auth/libraries/dotnet.md#public-apps-desktop--actionwait-sign-in)). ## 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`](https://github.com/AltiumDeveloper/altium-auth/blob/main/spec/conformance/vectors.json) — the exact contract the TypeScript library passes. This proves the two implementations are behavior-identical. Spec: [`spec/SPEC.md`](https://github.com/AltiumDeveloper/altium-auth/blob/main/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: ```bash # 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: ```bash # 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 --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](https://github.com/AltiumDeveloper/altium-auth/blob/main/CONTRIBUTING.md) and [AGENTS.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/AGENTS.md) for the repo-wide, spec-first contribution model. ## Security Please report vulnerabilities privately — see [SECURITY.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/SECURITY.md). ## License [MIT](https://github.com/AltiumDeveloper/altium-auth/blob/main/LICENSE) © Altium Limited --- # altium-auth (Python) [![PyPI](https://img.shields.io/pypi/v/altium-auth?label=pypi)](https://pypi.org/project/altium-auth/) [![Python](https://img.shields.io/pypi/pyversions/altium-auth.svg)](https://pypi.org/project/altium-auth/) [![CI](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/python-ci.yml/badge.svg)](https://github.com/AltiumDeveloper/altium-auth/actions/workflows/python-ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/python/LICENSE) Altium 365 OAuth2 / OpenID Connect authentication for Python. Supports both client types across Commercial Cloud, GovCloud, and AES (on-prem): - **Public clients** (desktop, native) — browser sign-in with PKCE over Altium's **ActionWait** long-poll: `AltiumAuthClient.sign_in`. - **Confidential clients** (web/server backends) — the standard **authorization-code redirect** flow: `create_authorization_url` + `exchange_code`. - **Workspace tokens**, **refresh**, **revocation**, and first-class **GovCloud** + **AES** support. **Zero runtime dependencies** (stdlib only). Python 3.10+. ## Documentation The library implements the protocol described in the language-neutral guides (start here if you're new to Altium Identity): - [Authentication overview](https://altiumdeveloper.github.io/altium-auth/guides/overview/) - [Register your application](https://altiumdeveloper.github.io/altium-auth/guides/register-your-application/) - [Web / server apps](https://altiumdeveloper.github.io/altium-auth/guides/web-and-server-apps/) - [Desktop apps](https://altiumdeveloper.github.io/altium-auth/guides/desktop-apps/) - [GovCloud](https://altiumdeveloper.github.io/altium-auth/guides/govcloud/) - [AES (on-prem)](https://altiumdeveloper.github.io/altium-auth/guides/aes/) - [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims/) ## Installation ```bash pip install altium-auth ``` ## Quick start Construct an `AltiumAuthClient` once with an `AltiumAuthConfig`. Only `client_id` and `scopes` are required — endpoints default to the Commercial Cloud. ### Public apps (desktop — ActionWait sign-in) ```python from altium_auth import AltiumAuthClient, AltiumAuthConfig client = AltiumAuthClient(AltiumAuthConfig(client_id="your-client-id", scopes="openid profile")) # Opens a browser login page and waits for the callback. tokens = client.sign_in() # Persist `tokens` yourself — a keyring, an OS credential store, or a file. workspace = client.sign_into_workspace(tokens.access_token, "workspace-id-here") ``` Provide a custom browser opener (e.g. an IDE/host bridge) via config; the library still owns PKCE, ActionWait polling, state correlation, CSRF validation, and the token exchange: ```python config = AltiumAuthConfig( client_id="your-client-id", scopes="openid profile", open_browser=lambda url: my_host.open_external(url), ) ``` ### Confidential apps (web / server — authorization-code redirect) ```python from altium_auth import AltiumAuthClient, AltiumAuthConfig client = AltiumAuthClient( AltiumAuthConfig( client_id="your-client-id", client_secret="your-client-secret", # confidential client → HTTP Basic scopes="openid profile offline_access", ) ) redirect_uri = "https://my-service.example.com/oauth/callback" # On your login route: build the URL, stash state + verifier, then redirect. req = client.create_authorization_url(redirect_uri=redirect_uri) session["oauth"] = {"state": req.state, "code_verifier": req.code_verifier} # redirect(req.url) # On your callback route: verify state, then exchange the code. if request.args["state"] != session["oauth"]["state"]: raise ValueError("state mismatch") tokens = client.exchange_code( request.args["code"], code_verifier=session["oauth"]["code_verifier"], redirect_uri=redirect_uri, ) ``` ### GovCloud ```python from altium_auth import AltiumAuthClient, AltiumAuthConfig, GOV_CLOUD_ENDPOINTS client = AltiumAuthClient( AltiumAuthConfig( client_id="your-gov-client-id", scopes="openid profile", endpoints=GOV_CLOUD_ENDPOINTS, ) ) tokens = client.sign_in() # secure=1 is added to token requests automatically ``` ### AES (on-prem) ```python from altium_auth import AltiumAuthClient, AltiumAuthConfig, aes_endpoints endpoints = aes_endpoints("https://aes.server.example:9785") scopes = AltiumAuthClient.get_client_scopes(endpoints.scope_endpoint, "your-aes-client-id") client = AltiumAuthClient( AltiumAuthConfig( client_id="your-aes-client-id", scopes=" ".join(scopes), endpoints=endpoints, ) ) tokens = client.sign_in() ``` ## Using from async code The library is synchronous. From an event loop, bridge with the stdlib: ```python import asyncio tokens = await asyncio.to_thread(client.refresh_token, refresh_token) ``` ## API reference | Method | Description | | --- | --- | | `create_authorization_url(*, redirect_uri=None, state=None, code_verifier=None, scopes=None, select_workspace=WorkspaceSelection.NONE)` | Build a PKCE authorization URL (no I/O). Returns `AuthorizationRequest(url, state, code_verifier)`. | | `exchange_code(code, *, code_verifier=None, redirect_uri=None, timeout=30.0)` | Authorization-code grant → `TokenSet`. | | `sign_in(*, select_workspace=WorkspaceSelection.NONE, timeout=180.0)` | Full ActionWait sign-in → `TokenSet`. | | `sign_into_workspace(base_access_token, workspace_auth_id, *, timeout=30.0)` | RFC 8693 workspace exchange → `TokenSet`. | | `refresh_token(refresh_token, *, timeout=30.0)` | Refresh grant (no scope resent) → `TokenSet`. | | `revoke_refresh_token(refresh_token, *, timeout=30.0)` | RFC 7009 revocation (idempotent). | | `AltiumAuthClient.get_client_scopes(scope_endpoint, client_id, *, timeout=30.0)` | Static; scope introspection → `list[str]`. | ### Types - `AltiumAuthConfig(client_id, scopes, client_secret=None, endpoints=COMMERCIAL_CLOUD_ENDPOINTS, secure=None, open_browser=None)` — properties `is_confidential`, `use_secure`. - `AltiumEndpoints(authorize_endpoint, token_endpoint, action_wait_endpoint, redirect_uri, scope_endpoint=None)` — constants `COMMERCIAL_CLOUD_ENDPOINTS`, `GOV_CLOUD_ENDPOINTS`; factory `aes_endpoints(origin)`. - `TokenSet(access_token, token_type, expires_in, expires_at, refresh_token, id_token, scope)` — `expires_at` computed with a 30 s clock-skew buffer. - `WorkspaceSelection` — `NONE` / `STRICT` / `OPTIONAL`. > `access_token` is a signed JWT — decode it to read `iss`, `workspaceId`, `secure`, and scopes. See [Access token claims](https://altiumdeveloper.github.io/altium-auth/guides/token-claims/). ## Error handling All errors subclass `AltiumAuthError`: | Class | When | | --- | --- | | `ConfigurationError` | Missing `client_id`/`scopes`, invalid endpoint URL, or empty required argument. | | `OAuthError` | Token/revocation/scope endpoint returned a non-success status (carries `.status`, `.error`, `.error_description`). | | `ActionWaitError` | ActionWait timed out, was cancelled (410), or returned an unusable body. | | `StateMismatchError` | Returned state ≠ wait token (CSRF guard). Subclass of `ActionWaitError`. | | `TransportError` | Network-level failure. | | `TlsError` | TLS/certificate failure. Subclass of `TransportError`. | ## Development ```bash uv sync --extra dev uv run pytest # unit + conformance uv run ruff check . uv run mypy uv build ``` ### Live E2E sign-in ```bash uv run python tools/signin_test.py YOUR_CLIENT_ID uv run python tools/signin_test.py --env gov YOUR_GOV_CLIENT_ID uv run python tools/signin_test.py --env aes --aes-origin https://aes.server.example:9785 YOUR_AES_CLIENT_ID ``` ## Security Report vulnerabilities privately — see [SECURITY.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/SECURITY.md). ## License [MIT](https://github.com/AltiumDeveloper/altium-auth/blob/main/libs/python/LICENSE) © Altium Limited --- # Altium Identity Integration Specification **Version:** 0.2.0 (DRAFT) **Status:** extracted from the validated `@altium-developer/altium-auth` reference implementation and `docs/`, and cross-validated by the .NET (`Altium.Auth`) implementation against the shared conformance vectors. Preview — subject to change as the libraries evolve. The key words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are used per [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119). This document specifies **only the Altium-specific parts** of the flow. Everything else follows the referenced standards and SHOULD be implemented with a certified OIDC/OAuth client rather than re-implemented: - OAuth 2.0 — RFC 6749 · PKCE — RFC 7636 · Token Exchange — RFC 8693 - Token Revocation — RFC 7009 · OIDC Core · OIDC Discovery · RFC 8414 --- ## 1. Environments and endpoints Altium Identity runs as an OpenID Connect provider, one **base URL per environment**. A client targets exactly one environment. | Name | Base URL | Discovery document | | --- | --- | --- | | Commercial Cloud | `https://auth.altium.com` | `https://auth.altium.com/.well-known/openid-configuration` | | GovCloud | `https://auth.365-gov.altium.com` | `https://auth.365-gov.altium.com/.well-known/openid-configuration` | | AES (on-prem) | `{origin}/unifiedlogin` (customer-hosted) | `{origin}/unifiedlogin/.well-known/openid-configuration` | - Clients **SHOULD** resolve endpoints from the discovery document; hardcoded base URLs **MAY** be used as a fallback. - Standard endpoints (all under the base URL): `/connect/authorize`, `/connect/token`, `/connect/userinfo`, `/connect/revocation`. - Proprietary scope-introspection endpoint (all environments): `{base}/api/ClientScopes` — e.g. `https://auth.altium.com/api/ClientScopes`, or `{origin}/unifiedlogin/api/ClientScopes` on AES. What it usefully returns differs by environment (§6). - The base URLs above are the production hosts; a deployment **MAY** expose the same endpoints under different hosts, so treat base URLs as configuration rather than constants. - **AES (on-prem)** is not an Altium-hosted domain — it runs on a customer-controlled origin (e.g. `https://aes.server.example:9785`). The base URL is that origin plus `/unifiedlogin` (e.g. `https://aes.server.example:9785/unifiedlogin/connect/authorize`). Each AES installation is its own environment. ### 1.1 ActionWait host (proprietary) The **ActionWait** service is a single Commercial-Cloud deployment — it has **no** Gov-specific host. In production it is: | Service | Endpoint | | --- | --- | | ActionWait poll | `https://actionwait.altium.com/await` | By contrast, AES installations host their **own** ActionWait service, at `{origin}/actionwait/await` on the same origin as its base URL — it does not share Commercial's ActionWait deployment. --- ## 2. Client types | Type | Credential | Redirect | Auth at token endpoint | | --- | --- | --- | --- | | **Confidential** (web/server) | `client_secret` | App-hosted callback | HTTP Basic (`client_secret_basic`) | | **Public** (desktop) | none (PKCE) | ActionWait (`§4`) | `client_id` in the request body | - Public clients **MUST NOT** hold a secret and **MUST** use PKCE (§3). - Confidential clients **MUST** authenticate at the token endpoint with HTTP Basic and **SHOULD** also use PKCE. --- ## 3. PKCE (RFC 7636) - Clients **MUST** use PKCE with `code_challenge_method=S256`. - The `code_verifier` **MUST** be a high-entropy random string (reference: base64url of 32 random bytes). - `code_challenge = base64url(SHA256(code_verifier))`. --- ## 3.1 `selectWorkspace` — login-into-workspace mode (proprietary) The `/connect/authorize` endpoint accepts an optional `selectWorkspace` query parameter that controls whether the user is prompted to select a workspace during the authorization flow. | Value | Behavior | | --- | --- | | `none` (default) or omitted | Workspace selection is skipped; the flow issues a global access token. | | `strict` | Workspace selection is **mandatory** — the user must choose a workspace before authentication can complete. | | `optional` | Workspace selection is presented to the user but may be skipped. | When a workspace is selected during the authorization flow, the issued access token is already scoped to that workspace (`a365:workspace:`), eliminating the need for a separate token-exchange step (§5.2). Rules: - `selectWorkspace` **MUST NOT** be sent on the token endpoint — it is an authorize-only parameter. - When `selectWorkspace` is `none` or omitted, the parameter **SHOULD** be omitted from the URL. - The `secure=1` rule (§5.4) still applies at the token endpoint regardless of which `selectWorkspace` value was used. --- ## 4. ActionWait (proprietary — public clients) Public clients cannot host a redirect. ActionWait delivers the authorization result to a waiting client over an HTTP long poll. ### 4.1 The linchpin The client generates one high-entropy value used **simultaneously** as: - the OAuth `state` parameter in the authorize request, **and** - the ActionWait **wait token**. The Altium-hosted callback correlates the browser's `code`+`state` to the waiting poll by matching `state` to the wait token. Verifying the returned `state` equals the generated value is also the CSRF check. ### 4.2 Redirect URI - The authorize request's `redirect_uri` **MUST** be the Altium-hosted `AuthComplete` callback on the **Commercial Cloud** host for the tier — e.g. `https://auth.altium.com/api/AuthComplete` (Production), including for Gov sign-in. A gov-host callback is not registered and **will** fail authorization. - For **AES** installations, the `redirect_uri` **MUST** be its own `AuthComplete` callback at `{origin}/unifiedlogin/api/AuthComplete`. ### 4.3 Poll protocol — `POST {actionWaitHost}/await` Request body (`Content-Type: application/json`): ```json { "token": "" } ``` Responses: - `200` — result ready. The body's `data` object carries **either** the authorization result `{ "code": "", "state": "" }` **or** a delivered OAuth error `{ "error": "", "error_description"?: "", "state": "" }` (e.g. `access_denied` — including when a workspace scope is requested at sign-in on the wrong partition, §6). On the success shape the client **MUST** verify `data.state` equals its wait token before using `data.code`; on the error shape it **MUST** surface `error` (terminal — do not reconnect). A `200` whose `data` has neither `code` nor `error` is a protocol error. - `408` — the poll's server-side hold interval elapsed. This is **normal**; the client **MUST** immediately reconnect with the **same** token. The hold interval is an unspecified implementation detail and clients **MUST NOT** depend on any particular duration; it is distinct from the client's own overall sign-in timeout, which spans the whole reconnect loop. - `410` — the wait token is no longer valid (consumed/superseded). Terminal; the client **MUST** restart sign-in with a new token. - Other status — treat as an error. ### 4.4 Ordering The client **MUST** start the `/await` poll **before** opening the browser, so a fast sign-in cannot deliver the result before the client is listening. --- ## 5. Token requests All tokens are obtained from `{base}/connect/token`. Client authentication per §2. ### 5.1 Authorization code (RFC 6749 + PKCE) `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. ### 5.2 Workspace token exchange (RFC 8693) `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, `subject_token=`, `subject_token_type=urn:ietf:params:oauth:token-type:access_token`, `scope` includes `a365:workspace:{workspaceId}`. A workspace token can also be obtained in a **single** trip, without this grant, by requesting `a365:workspace:{workspaceId}` in the `scope` at `/authorize` (§3.1) — the issued access token is then already workspace-scoped. Use the exchange when the workspace is chosen *after* sign-in (e.g. the user picks from the workspaces discovered in §7); use the scope at sign-in when the workspace ID is known up front. **AES** supports this grant like Commercial (no `secure=1`, §5.4), but rarely needs it: an installation hosts exactly one workspace, whose scope the client can learn up front (§6), so the one-trip form above is the expected flow. ### 5.3 Refresh (RFC 6749 §6) `grant_type=refresh_token`, `refresh_token`. - Clients **MUST NOT** send `scope` on refresh (the grant retains the token's original scope — a global token stays global, a workspace token stays workspace-scoped). - The provider **MAY** rotate the refresh token; if a new one is returned the client **MUST** persist it and discard the old. ### 5.4 `secure=1` (Commercial vs Gov) - A token request to a **Gov** token endpoint (`§6`) **MUST** include `secure=1`. - A token request to a **Commercial** token endpoint **MUST NOT** include `secure=1`. - A token request to an **AES** token endpoint **MUST NOT** include `secure=1` — AES follows the same rule as Commercial. - This applies to **all** grants in §5. `secure=1` is a token-endpoint parameter and **MUST NOT** be sent on `/authorize`. --- ## 6. Commercial vs Gov (proprietary) Commercial and Gov are kept strictly separate. A token's environment is its issuer (`iss`), set by the host it signed in on. **Recommended flow (works for public and confidential):** 1. Sign in on **Commercial Cloud** → global access token (`iss = https://auth.altium.com`). 2. Discover the user's workspaces with the global token (§7). 3. Exchange the global token for a **workspace** token at the endpoint matching the workspace: - Commercial workspace → Commercial token endpoint, **no** `secure=1`. - Gov workspace → Gov token endpoint, **with** `secure=1`. The result has `iss = https://auth.365-gov.altium.com` and a `secure` claim. 4. Call the API with the workspace token; refresh at the endpoint that issued it. Rules (validated): - A global token **MUST** be exchanged for a workspace on the matching side; a mismatched exchange returns **`access_denied`**: - Gov workspace on the Commercial endpoint → `access_denied`. - Commercial workspace on the Gov endpoint → `access_denied`. - Gov endpoint **without** `secure=1` → `access_denied`. - A Gov (`secure`) token **MUST NOT** be presented to Commercial/global services, and vice versa. - **Same environment only.** The Commercial→Gov exchange happens between the Commercial and Gov hosts of the **same deployment/environment**. A token's issuer must be trusted by the exchange endpoint, so presenting a token to a *different* environment's endpoint is rejected with **`invalid_request` / `invalid_token`**. Host detection (reference heuristic): a token endpoint is Gov iff its host contains a `gov` label (e.g. `auth.365-gov.altium.com`). Implementations **MAY** allow an explicit override for non-standard hosts. **AES** is its own environment (§1), distinct from both Commercial and Gov; each AES installation has exactly one workspace. Token requests follow the Commercial rule — never `secure=1` (§5.4) — and the workspace token-exchange grant (§5.2) *is* available, but is rarely needed: with a single workspace, its ID is known up front, so clients **SHOULD** request `a365:workspace:{workspaceId}` in the sign-in `scope` and get a workspace token in one trip. There is no `selectWorkspace` prompt on AES (§3.1) — with one workspace there is nothing to choose; requesting the workspace scope at sign-in is how a client "logs into the workspace". The "same environment only" rule above still holds: an AES-issued token is not accepted by Commercial/Gov endpoints and vice versa. A client that does not already know the workspace ID **MAY** introspect the scopes registered for it at `{base}/api/ClientScopes?clientId={clientId}` (`GET`), which returns a JSON array of scope strings. The endpoint exists in **every** environment (§1), but only on AES does the response include an `a365:workspace:{workspaceId}` scope — the installation hosts exactly one workspace, so it *is* implied by the client ID. On Commercial/GovCloud the response carries only the client's static scopes (e.g. `openid`, `profile`): a Cloud client may reach many workspaces, none of them derivable from the client ID alone, so discover those via §7 instead. --- ## 7. Workspace discovery Discover the user's workspaces with the global access token (GraphQL): ```graphql query { desWorkspaceInfos { authId name url location { name } } } ``` - `authId` is the `{workspaceId}` used in §5.2. - A workspace is in **GovCloud** iff `location.name = "US GovCloud"` — the current recommended signal for choosing the exchange endpoint. > OPEN: confirm the `des` prefix and the global discovery GraphQL endpoint with the API team. --- ## 8. Access token claims Access tokens are signed JWTs (`typ: at+jwt`, `alg: RS256`). Verify the signature against the issuer's JWKS before trusting claims. Altium-relevant claims: | Claim | Present on | Meaning | | --- | --- | --- | | `iss` | all | Issuer → the environment/cloud | | `sub` | all | User ID (for user tokens) | | `client_id` | all | The application (OAuth client) | | `scope` | all | Granted scopes (`a365:workspace:{id}`, `offline_access`, …) | | `workspaceId` | workspace tokens | The workspace `authId`; absent on global tokens and on all AES tokens | | `secure` | Gov tokens | `"1"`; absent on Commercial and AES tokens | See `schemas/access-token-claims.schema.json`. --- ## 9. Revocation (RFC 7009) - Only **refresh tokens** can be revoked. `POST {base}/connect/revocation` with `token=`, `token_type_hint=refresh_token`, client auth per §2. Success is `200` with an empty body (also `200` for unknown/already-invalid tokens). - Access tokens are self-contained JWTs and **cannot** be revoked; they remain valid until `exp`. To cut off access, revoke the refresh token and keep access-token lifetimes short. --- ## 10. Error contract (observed) | Situation | Response | | --- | --- | | Cross-partition / missing-`secure` workspace exchange | `400 { "error": "access_denied" }` | | Subject token from a different environment (cross-tier exchange) | `400 { "error": "invalid_request", "error_description": "invalid_token" }` | | Expired/revoked refresh token, bad code, PKCE mismatch | `400 { "error": "invalid_grant" }` | | Bad client credentials | `400 { "error": "invalid_client" }` | Errors follow the OAuth 2.0 error response shape (`error`, optional `error_description`). --- ## References - Conceptual guides: [docs/](https://github.com/AltiumDeveloper/altium-auth/tree/main/docs) (overview, web/server, desktop, gov-cloud, AES, register, token-claims) - Reference implementations: `libs/typescript/` (TypeScript) and `libs/dotnet/` (.NET) - Conformance vectors: [spec/conformance/](https://github.com/AltiumDeveloper/altium-auth/tree/main/spec/conformance) --- # Conformance vectors Language-neutral test vectors that pin down the Altium-specific behavior in [SPEC.md](https://github.com/AltiumDeveloper/altium-auth/blob/main/spec/SPEC.md). Every SDK (TS, .NET, Java, …) should run these against a mock IdP and assert the same outgoing requests and outcomes, so all implementations stay consistent. Source of truth: `../../src/index.test.ts` (unit behavior) and the live Commercial/Gov/Dev captures (Tests A/B/C + dev confidential) recorded during validation. ## File - `vectors.json` — the vectors, grouped by category. ## Categories & matchers Values in `expect` blocks may be literals or one of these matchers (strings): - `""` — key/param must be present, any value. - `"contains:"` — string value must contain ``. - `"basic(clientId:clientSecret)"` — an `Authorization` header of `Basic base64(clientId ":" clientSecret)`. - `"none"` — (for `authorization`) the header must be absent. - `"epochWithin::"` — (for a numeric result field like `expires_at`) the value must be within `` seconds of `now + `. ### `authorizeUrl` Input: `config` + `options` (a fixed `codeVerifier`/`state` make it deterministic). `expect`: `origin`, `pathname`, `query` (exact key→value), `queryAbsent` (keys that must not appear). ### `tokenRequest` Input: `operation` (`exchangeCode` | `signIntoWorkspace` | `refreshToken`), `config`, `input`. `expectRequest`: `endpoint`, `method`, `authorization`, `bodyParams` (key→value/matcher), `bodyParamsAbsent`. Then either `mockResponse` + `expectResult`, or `mockResponse` + `expectErrorContains`. ### `actionWait` Input: `pollResponses` (an ordered list the mock returns to successive `POST /await` calls). `expect`: `outcome` (`code` | `errorContains`) — verifies `408`=reconnect, `410`=terminal, `200` parse rules. ### `liveClaims` Golden decoded **access-token claims** from real runs — integration references (assert after decoding a token obtained from the live server for that scenario). ### `clientScopes` Input: `input` (`scopeEndpoint`, `clientId`). Drives `getClientScopes`/`GetClientScopesAsync` (the `{base}/api/ClientScopes` endpoint — present in every environment, but only AES returns an `a365:workspace:{id}` scope; not really part of the OAuth flows in the other categories). `mockResponse`: the GET response. `expectRequest` (optional): `endpoint` (full URL, query string included), `method`. Then either `expectResult` (the returned scope array) or `expectErrorContains` — a lookup that fails **must** throw rather than degrade to `[]`, which is itself a meaningful answer ("this client has no scopes"). ## Coverage checklist - [x] Authorize URL — Commercial + Gov + AES host, `secure` never on `/authorize`, PKCE `S256` - [x] Authorize URL — `selectWorkspace` strict/optional/none (SPEC §3.1) - [x] Authorize URL — AES workspace scope requested directly (one trip, no exchange needed, SPEC §5.2/§6) - [x] Code exchange — public (client_id) vs confidential (Basic); Commercial + AES token hosts - [x] Workspace exchange — non-Gov (no `secure`) vs Gov (`secure=1`, Commercial→Gov bridge) vs AES (available, no `secure`) - [x] Refresh — no `scope` sent; Commercial vs Gov (`secure`) vs AES (no `secure`) - [x] Cross-partition exchange → `access_denied` - [x] ActionWait — 200/408/410, non-JSON, missing `code` - [x] Live claims — `iss` / `secure` / `workspaceId` for global, non-Gov, Gov, AES tokens - [x] ClientScopes — scope array returned, unknown-client empty array, malformed body + non-200 → error (AES host; only AES returns a workspace scope) - [x] Revocation — request shape + refresh → `invalid_grant` (spec §9; skipped in the TS runner, no `revoke()` in the lib) - [x] userinfo response shape (`../schemas/userinfo.schema.json`; reference) - [x] `expires_at` computation (30s skew) - [x] State-mismatch (CSRF) rejection after ActionWait `200` ## Next - Wire `../../src/index.test.ts` to load `vectors.json` (proves the vectors match the reference). - Stand up a shared mock IdP (WireMock/Prism, or recorded interactions) so non-TS SDKs run the same vectors.