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.
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.
POST /v2/hrd/resolve
GET /v2/authorize
POST /v2/token
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
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.
-
Get your
client_app_idTell 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. -
Install your platform's SDK
Web is
@xirag/gate-web(npm), Windows and Linux sharegate-desktop(Go module), Android'sGateClient.ktis a reference implementation you copy into your project — there's no packaged Android artifact yet. -
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
statusbefore proceeding. -
Send the browser to
authorize_urlYour SDK appends PKCE and state parameters for you. The user authenticates with their own organization's identity provider — Gate never sees their password.
-
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.
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.
The authentication model, briefly
Three ideas do all the work. Understanding them makes every SDK call below make sense instead of feeling like magic.
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.
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 — 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.
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
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.
});
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)
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)
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.
- If
MainActivityextendsAppCompatActivity, your app's theme must be anAppCompatdescendant — otherwise it's an immediateIllegalStateExceptiononsetContentView, 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
networkSecurityConfigscoped to exactly the hosts involved, not a blanketusesCleartextTraffic="true".
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 & 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).
Loading interactive API explorer…
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"
}
| Code | Where it comes from | What it means |
|---|---|---|
DOMAIN_NOT_VERIFIED | Discovery | The domain hasn't completed DNS ownership verification yet. |
IDP_UNREACHABLE | Authorization | The organization's own identity provider didn't respond. |
IDP_ASSERTION_INVALID | Authorization | The IdP's response failed signature or claims verification. |
TUNNEL_UNAVAILABLE | Token | Your registered backend couldn't be reached to complete session establishment. |
TOKEN_REPLAY | Token / your backend | A handover token or DPoP proof was presented more than once. |
DEVICE_NOT_TRUSTED | Your backend | The signing device isn't the one currently trusted for this identity. |
JWKS_STALE | Your backend | Your cached key bundle is too old to trust — refetch /v2/jwks/bundle. |
RATE_LIMITED | Discovery | Too many resolve calls from this IP/device — back off before retrying. |
IDENTITY_REVOKED | Token | This user's access was revoked; they need a fresh sign-in. |
INVALID_REQUEST | Any service | Malformed request — check required headers and body shape. |
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.
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.
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.