Xirag Gate / Developers
Product page
// developer_corner

Build on Xirag Gate

Everything you need to add Gate sign-in to a web app, a native Windows or Linux app, or an Android app — Home Realm Discovery, OAuth 2.1 + PKCE, DPoP-bound tokens, and a real, browsable OpenAPI 3.1 reference.

OAuth 2.1 PKCE · S256 DPoP · RFC 9449 OpenAPI 3.1
// overview

How a Gate sign-in actually flows

Gate is a broker with three services your app talks to, in order — plus your own backend at the end of the chain. No step here holds your customer's user directory, and nothing but the last hop ever touches your infrastructure.

1 Discovery HRD service POST /v2/hrd/resolve
2 Authorization Broker service GET /v2/authorize
3 Token exchange Token service POST /v2/token
4 Your backend Session established POST /auth/gate/session

Domain in, sign-in URL out

You send only the domain half of an email address to Discovery — never the full address. It always returns 200, with a URL to send the user's browser to next.

The system browser, always

Authorization happens in the OS's real browser or a Custom Tab — never an embedded WebView. That's what makes phishing-by-fake-login-form structurally impossible.

A token only your backend can open

The handover token is encrypted to your specific backend's public key. Nothing in between — not even Gate's own broker — can read what's inside it.

// quickstart

Quickstart

You don't provision your own tenant by hand — a client_app_id and registered redirect_uri come from your Xirag Gate account team, the same way an OAuth app registration would from any provider. From there, five steps get you to a signed-in session.

  1. Get your client_app_id

    Tell your account team your platform (web, Windows, Linux, or Android) and your redirect_uri — for web that's your BFF's callback route; for desktop and Android it's a loopback URL or a custom URI scheme. See platform guides below.

  2. Install your platform's SDK

    Web is @xirag/gate-web (npm), Windows and Linux share gate-desktop (Go module), Android's GateClient.kt is a reference implementation you copy into your project — there's no packaged Android artifact yet.

  3. Call discover(domain)

    Resolves the user's organization to a sign-in URL. Always returns a result, even for domains Gate doesn't recognize yet — check status before proceeding.

  4. Send the browser to authorize_url

    Your SDK appends PKCE and state parameters for you. The user authenticates with their own organization's identity provider — Gate never sees their password.

  5. Exchange the code, establish your session

    exchangeCode() gets you a DPoP-bound handover token; establishSession() presents it to your own backend, which validates it and starts whatever session model your app already uses.

Evaluating the reference stack locally?

The open-source reference implementation's Admin API has no authentication at all — a deliberate, documented local-dev-only shortcut, never exposed beyond localhost. Production tenant and client-app registration goes through your account team, not a self-serve API call. If you're running the stack locally to kick the tires, ask your account contact for the local evaluation guide.

// auth_model

The authentication model, briefly

Three ideas do all the work. Understanding them makes every SDK call below make sense instead of feeling like magic.

HRD

Home Realm Discovery

One email domain, one organization, one identity provider. Discovery is the lookup that makes "which company do you work for" invisible to your users.

PKCE

OAuth 2.1 + PKCE (S256)

A code-interception attack against the authorization step is mitigated by design — the code is useless to anyone who doesn't hold the verifier your app generated.

DPoP

DPoP — RFC 9449

Every sensitive call is signed by a private key your app holds and never sends anywhere. A stolen token is useless without the key that proves it's yours.

The token your app receives — the handover token — isn't a plain JWT you can inspect. It's a signed assertion nested inside an encrypted envelope, sealed specifically to your registered backend's public key (ECDH-ES + A256GCM around an EdDSA-signed JWS). Nothing between Discovery and your backend — including Gate's own broker — can read what's inside it. Your backend decrypts, verifies, and is the only party that ever sees the claims.

Where does the DPoP key live?

That's the one fact that actually differs by platform, and it's why the SDKs below don't all look the same. Web runs the whole flow server-side in your own backend-for-frontend — the browser never holds a key or a token, only an HttpOnly session cookie your backend issues at the end. Windows, Linux, and Android hold their own device-bound key directly (Android in the hardware Keystore, StrongBox-preferred) and talk to Discovery, Authorization, and Token with no intermediary at all.

// platform_guides

Platform guides

Every SDK implements the same four calls — configure → discover → authorize → exchange/establish — shaped to fit how each platform actually works.

Package: @xirag/gate-web · Pattern: backend-for-frontend (BFF) — runs in your Node server, never in browser JavaScript.

import { GateClient } from "@xirag/gate-web";

// Once, at server startup — generates and holds this process's DPoP key.
const client = await GateClient.configure({
  clientAppId: "<your registered client_app_id>",
  hrdBaseUrl: "https://hrd.your-domain.example",
  tokenBaseUrl: "https://token.your-domain.example",
  redirectUri: "https://yourapp.example/callback",
});

app.post("/login", async (req, res) => {
  const domain = req.body.email.split("@")[1];
  const discovered = await client.discover(domain, deviceId, req.get("user-agent"));
  const pending = await client.beginAuthorize(discovered);
  // Persist `pending`, keyed by pending.state, in a real server-side
  // session store with a TTL — not an in-memory Map with no expiry.
  res.redirect(pending.redirectTo);
});

app.get("/callback", async (req, res) => {
  const pending = lookupByState(req.query.state);
  const tokenResult = await client.exchangeCode(req.query.code, pending, req.get("user-agent"));
  const session = await client.establishSession(tokenResult, req.get("user-agent"));
  // session.name, session.email, session.tenantId — set your own cookie here.
});
Forward the browser's real User-Agent.

The third argument to discover/exchangeCode/establishSession forwards the browser's User-Agent to Gate — without it, every login through your BFF looks identical to your Node process, which breaks per-device-category session policy for every user.

Package: gate-desktop (Go) · Pattern: native key custody, system browser + loopback redirect (RFC 8252 §7.3).

import gatedesktop "github.com/xirag/gate/sdk/gate-desktop"

client, err := gatedesktop.Configure(gatedesktop.Config{
    ClientAppID:  "<your registered client_app_id>",
    HRDBaseURL:   "https://hrd.your-domain.example",
    TokenBaseURL: "https://token.your-domain.example",
    KeyPath:      keyPath, // persisted across launches, see below
    UserAgent:    "YourApp/1.0 (windows; amd64)",
})

discovered, err := client.Discover(ctx, domain)

// Opens the default browser via the OS shell, listens on a loopback
// port Windows/the OS assigns automatically, blocks until the redirect
// lands (or AuthTimeout, default 5 minutes), then exchanges the code.
tok, err := client.Authenticate(ctx, discovered)

session, err := client.EstablishSession(ctx, tok)
Why KeyPath persists across launches.

The DPoP key is the device's identity for session-policy purposes. A fresh key every launch looks like a brand-new device signing in every time — load-or-generate keeps it stable, exactly like a backend's own key.

Package: gate-desktop (Go) — the same SDK as Windows, one Go binary compiles for both. The only platform-specific piece is which shell command opens the system browser.

import gatedesktop "github.com/xirag/gate/sdk/gate-desktop"

client, err := gatedesktop.Configure(gatedesktop.Config{
    ClientAppID:  "<your registered client_app_id>",
    HRDBaseURL:   "https://hrd.your-domain.example",
    TokenBaseURL: "https://token.your-domain.example",
    KeyPath:      keyPath,
    UserAgent:    "YourApp/1.0 (linux; amd64)",
})

discovered, err := client.Discover(ctx, domain)
tok, err := client.Authenticate(ctx, discovered) // xdg-open under the hood
session, err := client.EstablishSession(ctx, tok)
Verified under real conditions, not just compiled.

The Linux path was run as a real compiled ELF binary under WSL2, genuinely invoking xdg-open and completing a full sign-in against a live stack — not a code-review-only claim.

Reference: GateClient.kt — not a packaged artifact yet; copy the pattern into your app. Pattern: native key custody, Chrome Custom Tabs, custom URI scheme deep link (RFC 8252 §7.2).

// DeviceKey.kt — AndroidKeyStore EC P-256, StrongBox-preferred with a
// real fallback (confirmed correct on emulators, which have no StrongBox):
object DeviceKey {
    fun getOrCreate(): KeyStore.PrivateKeyEntry { /* setIsStrongBoxBacked(true),
        catch StrongBoxUnavailableException, fall back to hardware Keystore */ }
}

// GateClient.kt — direct to HRD/Broker/Token, no BFF:
class GateClient(clientAppId: String, hrdBaseUrl: String, tokenBaseUrl: String) {
    private val entry = DeviceKey.getOrCreate()
    val deviceId: String = Dpop.thumbprint(entry.certificate.publicKey as ECPublicKey)

    fun discover(domain: String): DiscoverResult { /* POST /v2/hrd/resolve, DPoP-signed */ }
    fun exchangeCode(code: String, verifier: String): TokenResult { /* POST /v2/token */ }
    fun establishSession(tok: TokenResult): SessionResult { /* POST <backend>/auth/gate/session */ }
}

// The authorize leg opens a Chrome Custom Tab (androidx.browser) — never
// an embedded WebView — and returns via a custom scheme deep link:
// xiraggate://callback, handled by a dedicated Theme.NoDisplay activity.
Two real crashes worth avoiding up front.
  • If MainActivity extends AppCompatActivity, your app's theme must be an AppCompat descendant — otherwise it's an immediate IllegalStateException on setContentView, and the manifest default doesn't warn you until runtime.
  • Android blocks cleartext HTTP by default from API 28 — against a non-TLS-terminated environment you need an explicit networkSecurityConfig scoped to exactly the hosts involved, not a blanket usesCleartextTraffic="true".
iOS & macOS — planned, not yet shipped.

No native SDK exists for either platform yet. The wire protocol underneath every SDK above — DPoP proofs, PKCE, the nested-JWS-in-JWE handover token — is entirely platform-agnostic; what's missing is purely the platform-specific key storage (Secure Enclave) and browser leg (ASWebAuthenticationSession). If an Apple-platform client is on your roadmap, tell your account team — that's real, scoped work we'd rather plan with a design partner than guess at.

// api_reference

API reference & Swagger

The full contract — five operations across three services — as an OpenAPI 3.1 document. Browse it below, or pull it into your own tooling (codegen, Postman, Insomnia — anything that reads OpenAPI).

Interactive API explorer Download spec ↓

Loading interactive API explorer…

// errors

Errors

Every service returns the same envelope on failure — trace_id is what to hand support if you need help with a specific request.

{
  "code": "IDP_ASSERTION_INVALID",
  "message": "The identity provider's response failed verification.",
  "trace_id": "9f2c1e7a-...",
  "help_url": "https://docs.gate.xirag.com/errors/idp_assertion_invalid"
}
CodeWhere it comes fromWhat it means
DOMAIN_NOT_VERIFIEDDiscoveryThe domain hasn't completed DNS ownership verification yet.
IDP_UNREACHABLEAuthorizationThe organization's own identity provider didn't respond.
IDP_ASSERTION_INVALIDAuthorizationThe IdP's response failed signature or claims verification.
TUNNEL_UNAVAILABLETokenYour registered backend couldn't be reached to complete session establishment.
TOKEN_REPLAYToken / your backendA handover token or DPoP proof was presented more than once.
DEVICE_NOT_TRUSTEDYour backendThe signing device isn't the one currently trusted for this identity.
JWKS_STALEYour backendYour cached key bundle is too old to trust — refetch /v2/jwks/bundle.
RATE_LIMITEDDiscoveryToo many resolve calls from this IP/device — back off before retrying.
IDENTITY_REVOKEDTokenThis user's access was revoked; they need a fresh sign-in.
INVALID_REQUESTAny serviceMalformed request — check required headers and body shape.
// operational

Rate limits & CORS

Rate limits

/v2/hrd/resolve is limited to 10 requests/minute and 100/hour per IP + device — a deliberate anti-enumeration control, not a bug. A breach returns 429 with an X-Gate-Challenge-Required header. Vary your device ID between test runs rather than working around it.

No CORS, anywhere — on purpose

Every SDK call is server-to-server or a native app, never browser JavaScript, so CORS never applies. If you're calling Discovery, Authorization, or Token directly from browser code, you've stepped outside the architecture — the fix is a BFF, like @xirag/gate-web, not a CORS header.

// gotchas

Common integration mistakes

Not hypothetical — every one below was a real bug hit while building and testing these exact SDKs.

DPoP htu must match the server's configured public URL

Not whatever host you literally dialed. Calling Token at one hostname while its configured public URL says another fails with "DPoP proof invalid" — a confusing error that has nothing to do with the proof's cryptography.

Pending-auth state needs a TTL

The BFF pattern's state-keyed pending-auth store will leak one entry per abandoned login (closed tab, dead network) if it's an in-memory map with no expiry. Use a real session store with a TTL.

Android's theme must be AppCompat

If your activity extends AppCompatActivity, an incompatible theme crashes on setContentView — and the manifest doesn't warn you until it happens at runtime.

Forward the real client User-Agent

In a BFF, your server's own User-Agent isn't the browser's. Without forwarding the real one explicitly, every login looks identical to Gate's per-device-category session policy.

// support

Get a client_app_id, or just talk to us

Tell us your platform and redirect URI, and we'll get you registered — usually the same day.