Connect your app with Xyte OAuth2

Let End Customers connect your application to their Xyte account with OAuth2 and OpenID Connect.

Xyte OAuth2 lets a third-party application act on an End Customer's Xyte account without handling API keys. Your application sends the user to Xyte, the user signs in and approves the connection, and your application receives tokens for the Organization Core API (the End Customer API documented at docs.xyte.io). Xyte implements the OAuth 2.0 authorization code grant with PKCE (RFC 6749, RFC 7636), refresh token rotation, token revocation (RFC 7009) and OpenID Connect for user identity.

All OAuth2 endpoints live on https://hub.xyte.io, outside the /core/v1/organization API prefix.

EndpointPurpose
GET /oauth/authorizeStarts the browser flow.
POST /oauth/tokenExchanges an authorization code or a refresh token for tokens.
POST /oauth/revokeRevokes the tokens issued to the authorization the token belongs to.
GET /oauth/userinfoReturns the signed-in user's identity (OpenID Connect). Also accepts POST.
GET /.well-known/openid-configurationOpenID Connect discovery document.
GET /oauth/.well-known/jwks.jsonPublic keys for verifying id_token signatures.

One flow, one level of access

There is a single flow, and it works the same way for every End User. What separates is identity from authority:

Comes fromWhat you get
AuthorityThe End Customer that the user picks on the consent screenAn access token with the same reach as an Organization Core API key. Every access and refresh token is issued against the End Customer's grant.
IdentityThe person who signed inAn id_token, and a GET /oauth/userinfo response, describing that person — whenever you request the openid scope.

The reach of an access token never depends on who signed in. An administrator and an ordinary member of the same End Customer receive tokens with identical access; only the identity in the id_token differs. Build your feature set around what the End Customer can do, not around the signed-in user's role.

An End Customer has to be approved once by one of its administrators before your application can act on it. That approval settles two things at once: the access every token carries, and who may sign in through the connection. The administrator picks a sign-in policy for it — anyone with access to the End Customer (the default), the members of one of its groups, or its administrators only — so "any End User can sign in" holds under the default and narrows from there. An administrator of the End Customer is always admitted, whatever the policy. The consent screen shows every End Customer a user can reach and says which of them they may approve or sign in to; see "What the user sees" below.

The policy is fixed when the connection is created, and there is no edit for it: an administrator who wants to change it revokes the connection under Settings → Connected apps and approves your application again. So an End User who signed in yesterday can be refused today because an administrator narrowed the policy. There is no new error for you to handle — the consent screen simply will not let that user pick the End Customer, so no code is issued and nothing comes back to your redirect URI. Tokens already issued are untouched by a later policy choice; only a revoke kills those.

Typical uses — a back-end integration that syncs Devices, Spaces or Incidents for a whole End Customer, and "Sign in with Xyte" for individual users of your product — are served by this one flow. Add openid to the scope when you need the user's identity; leave it out when you only need API access.

Register your application

Client registration is handled by Xyte in v1; there is no self-service form. Send Xyte:

  • The application name. Users see it on the consent screen, and End Customers see it in their API Call Log and under Connected apps.
  • The Partner that owns the application. Users approve the connection on that Partner's Customer Portal domain.
  • Every redirect URI your application uses, in full. The authorization endpoint matches redirect_uri byte for byte, so https://app.example.com/callback and https://app.example.com/callback/ are different URIs.

Xyte returns a client_id and a client_secret out of band. The secret is stored hashed and is never shown again; if you lose it or it leaks, ask Xyte to rotate it. Keep the secret on your server. Never ship it in a mobile or single-page application.

Tokens are opaque strings with a type prefix: xoac_ for authorization codes, xoat_ for access tokens and xort_ for refresh tokens. Do not parse them; the prefix only tells the token types apart.

A working sample application

Before writing your own client, run the one Xyte maintains: xyte-io/xyte-oauth2-demo. It is a complete third-party application — "Acme Fleet Portal" — in plain commented JavaScript, with no dependencies beyond Node 18.17+, and it implements every step of this guide: PKCE, the authorization redirect, the code exchange, id_token verification against the JWKS, /oauth/userinfo, refresh with rotation, replay of a rotated refresh token, and revocation.

git clone https://github.com/xyte-io/xyte-oauth2-demo && cd xyte-oauth2-demo
cp .env.example .env      # XYTE_HUB, and the client_id / client_secret Xyte issued you
node server.js            # http://localhost:5555

Point XYTE_HUB at https://hub.xyte.io and REDIRECT_URI at the URI Xyte registered for you, and the demo runs the real flow against your own application. Its dashboard shows what your client receives, with the id_token checked claim by claim:


Read it as a reference implementation rather than a starting template: sessions are in memory and tokens are rendered in the browser, so run it locally only.

Step by step

This walkthrough uses https://app.example.com/oauth/callback as the registered redirect URI and curl for the server-side calls.

1. Create a PKCE verifier and challenge

Generate a fresh code_verifier for every authorization request and keep it on your server until the code exchange. The challenge is base64url(SHA-256(code_verifier)) without padding.

code_verifier=$(openssl rand -hex 32)
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '=')

Generate an unguessable state value the same way and store it with the verifier (and with the nonce, if you request openid), keyed by the user's browser session.

2. Send the user to the authorization endpoint

GET /oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&state=1f7c2d8e9a4b&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=openid%20profile%20email&nonce=8b1e4c6d HTTP/1.1
Host: hub.xyte.io
ParameterRequiredValue
response_typeYescode.
client_idYesYour client id.
redirect_uriYesOne of your registered redirect URIs, byte for byte.
stateYesOpaque value bound to the browser session, at most 512 characters. Echoed back on the redirect.
code_challengeYesbase64url(SHA-256(code_verifier)).
code_challenge_methodYesS256. plain is rejected.
scopeNoSpace-separated subset of openid profile email. Any other value fails with invalid_scope. Omit it if you only need API access and never need to identify the user.
nonceRecommended with openidRandom value of at most 512 characters, echoed in the id_token. Verify it on return.

The endpoint validates client_id, redirect_uri and the length of state first. If client_id is unknown or the application is disabled (invalid_client), if redirect_uri is missing or unregistered, or if state is longer than 512 characters (invalid_request), Xyte shows a 400 error page and never redirects to the unverified URI. If the Partner that owns your application has no Customer Portal domain to host the consent screen, the error page is a 500 server_error. Otherwise it responds 302 to the consent screen on the Customer Portal domain of the Partner that owns your application, carrying the same query string. Any later validation error comes back to your redirect URI; see the OAuth2 error reference.

3. Handle the callback

After the user approves, the browser arrives at your redirect URI with a single-use code that expires in 60 seconds:

HTTP/1.1 302 Found
Location: https://app.example.com/oauth/callback?code=xoac_...&state=1f7c2d8e9a4b

If the user declines, the redirect carries an error instead:

HTTP/1.1 302 Found
Location: https://app.example.com/oauth/callback?error=access_denied&state=1f7c2d8e9a4b

On every callback:

  1. Look up the browser session and compare state with the stored value. Reject the callback on any mismatch and do not exchange the code.
  2. If error is present, stop and show the user a message. See the OAuth2 error reference.
  3. Exchange the code immediately. It is valid once, for 60 seconds.

4. Exchange the code for tokens

Authenticate with HTTP Basic, using client_id as the username and client_secret as the password (both form-url-encoded before Base64 encoding, per RFC 6749 section 2.3.1). The body must be application/x-www-form-urlencoded; a JSON body is rejected with invalid_request.

POST /oauth/token HTTP/1.1
Host: hub.xyte.io
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=xoac_...&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&code_verifier=3f9a...

The same request with curl:

curl --request POST https://hub.xyte.io/oauth/token \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/oauth/callback" \
  --data-urlencode "code_verifier=$code_verifier"
ParameterRequiredValue
grant_typeYesauthorization_code.
codeYesThe code from the callback.
redirect_uriYesThe exact redirect_uri used in step 2.
code_verifierYesThe verifier from step 1.

Alternatively, send client_id and client_secret as body parameters (client_secret_post). Do not combine both methods with different client ids; the request fails with invalid_request.

Response:

{
  "access_token": "xoat_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "xort_...",
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9..."
}
FieldPresentMeaning
access_tokenAlwaysBearer token for the Organization Core API. Valid for 60 minutes.
token_typeAlwaysBearer.
expires_inAlwaysAccess token lifetime in seconds (3600).
refresh_tokenAlwaysUse it once to obtain the next token pair. Expires 90 days after this exchange.
scopeOnly when you requested OpenID Connect scopesThe granted scopes.
id_tokenWhenever openid was requestedSigned JWT with the identity of the user who signed in.

Token responses carry Cache-Control: no-store and Pragma: no-cache. Store the refresh token encrypted at rest and keep the access token in memory as a short-lived secret.

5. Call the Organization Core API

Send the access token as a bearer token. Paths, parameters and responses are the same as with an API key; only the Authorization header changes.

GET /core/v1/organization/info HTTP/1.1
Host: hub.xyte.io
Authorization: Bearer xoat_...
curl https://hub.xyte.io/core/v1/organization/info \
  --header "Authorization: Bearer $ACCESS_TOKEN"

The response is the same as for Get Organization Info with an API key. Every call appears in the End Customer's API Call Log under your application name, and under the user who signed in.

6. Refresh the access token

When the access token expires, or the API answers 401, post the refresh token to the token endpoint with the same client authentication:

curl --request POST https://hub.xyte.io/oauth/token \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"

The response has the same shape as the code exchange and always contains a new refresh_token. The old refresh token is dead as soon as the new one is issued. When the grant includes openid, a new id_token is included as well (without a nonce). Persist the new refresh token before you use the new access token. The rotation section below explains what happens if you reuse an old one.

7. Revoke when the user disconnects

When a user disconnects your application or deletes their account with you, revoke the tokens so they stop working immediately:

curl --request POST https://hub.xyte.io/oauth/revoke \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode "token=$REFRESH_TOKEN" \
  --data-urlencode "token_type_hint=refresh_token"

The endpoint answers 200 with an empty body. The revocation section below has the details.

What the user sees

The consent screen runs on the Customer Portal of the Partner that owns your application, under the title Authenticate your application name.

  1. Sign-in. The user signs in with their Customer Portal credentials, or continues an existing session. The screen then names whoever that session belongs to, above the list, with a Not you? link that signs them out and returns to the same authorization request — the identity shown there is the one your id_token will carry.

  2. Tenant picker. The screen lists every End Customer the user can reach — nothing is filtered out — annotated with whether the user may approve it and whether your application is already approved for it. The list comes in three groups:

    • Approved by tenant administrator — an administrator has already approved your application here. Selectable; each row is hinted Sign in to this tenant.

    • Available to approve — End Customers the user administers, where your application is not approved yet. Selectable; picking one approves your application for that End Customer.

    • Requires administrator approval — End Customers the user can neither approve nor sign in to: your application is not approved there and the user does not administer it, or it is approved but its sign-in policy leaves the user out. Listed so the user can see them, but not selectable.

  3. Sign-in policy. Shown only when the picked End Customer is not connected yet. Nothing is preselected and Approve stays disabled until the administrator chooses, so the widest setting is never reached by clicking through.

    The administrator answers Who can sign in to your application name through this organization?, choosing Anyone with access to the organization (the default), Only members of a group — and then the group — or Only organization administrators. An End Customer that is already connected keeps the policy chosen when it was connected; this step is not shown for it.

  4. Approve. The user reviews the request and submits. The button reads Continue for an End Customer that is already approved, and Approve otherwise.

  5. Redirect. On approval the browser returns to your redirect URI with code and state. On decline it returns with error=access_denied and state.

Whichever End Customer the user picks, the code and the tokens are issued against that End Customer's grant — the user's own role in the Customer Portal changes nothing about them. That role does decide whether they may pick it at all, though: a row is selectable only for an administrator of that End Customer, or for a user the connection's sign-in policy admits.

📘

Users with nothing to pick

An End Customer shows up under Requires administrator approval, greyed out, in two cases: nobody has approved your application there and the user does not administer it, or it is approved but its sign-in policy — one group, or administrators only — leaves the user out. A user with nothing else on the screen cannot continue; no code is issued and nothing reaches your redirect URI. Tell such users to ask an administrator of that End Customer to approve your application, or to let them in; from then on they sign in like anyone else.

Token lifetimes

TokenLifetimeNotes
Authorization code60 secondsSingle use. Exchange it immediately.
Access token60 minutesexpires_in is 3600.
Refresh token90 days from the original code exchangeAbsolute, not sliding. Every refresh issues a new refresh token with the same expiry. After 90 days the user must authorize again.
id_token60 minutesexp is iat plus 3600.

Refresh token rotation and replay

Every refresh rotates the token: the response carries a new refresh token and the previous one is invalidated. Reusing an already-rotated refresh token is treated as a possible theft:

  • Within 60 seconds of the rotation, Xyte treats the reuse as a retried request whose response was lost. The token endpoint answers 400 invalid_grant and nothing else changes.
  • After 60 seconds, Xyte revokes the whole grant: every access token, refresh token and pending code your application holds for that End Customer, including those obtained by its other users. An audit event is recorded. Users have to go through authorization again.

To stay out of the second case, persist the new refresh token before you use it, run one refresh at a time per grant, and never share a refresh token between processes without coordination.

What an access token can do

Every access token behaves exactly like an Organization Core API key, whoever signed in to obtain it:

  • Devices, Spaces, assets, Incidents, Tickets, notes and Commands cover the whole End Customer. Nothing is filtered by the signed-in user's own Customer Portal access.
  • The endpoints that only an administrator can use in the portal — /core/v1/organization/users, /core/v1/organization/groups, Device merge and split — answer 200 for any OAuth2 access token.
  • Paths, parameters and response shapes are identical to API-key calls, so the same client code serves an API key and an access token.

Because every token has the same reach, never key your application's behaviour off the signed-in user's role: two users of the same End Customer see exactly the same data through your application. Use the id_token to learn who is using it, and the access token for what it may do. The connection's sign-in policy decides who may obtain a token at all; it never changes what a token, once issued, can reach.

OpenID Connect

Request the openid scope (plus profile, email or both) to receive an id_token, and use it to identify the user in your product. It is issued for every sign-in that asks for openid, including an administrator's.

Discovery and keys

GET /.well-known/openid-configuration HTTP/1.1
Host: hub.xyte.io
{
  "issuer": "https://hub.xyte.io",
  "authorization_endpoint": "https://hub.xyte.io/oauth/authorize",
  "token_endpoint": "https://hub.xyte.io/oauth/token",
  "userinfo_endpoint": "https://hub.xyte.io/oauth/userinfo",
  "jwks_uri": "https://hub.xyte.io/oauth/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "profile", "email"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nonce", "email", "name", "xyte_tenant_id", "xyte_tenant_type"]
}

The discovery document is cacheable for 1 hour. It does not advertise a revocation_endpoint yet; use POST /oauth/revoke as described on this page.

Signing keys:

GET /oauth/.well-known/jwks.json HTTP/1.1
Host: hub.xyte.io
{
  "keys": [
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "...", "n": "...", "e": "AQAB" }
  ]
}

Cache the JWKS for up to 5 minutes and refetch it when you meet an unknown kid.

The discovery document and the JWKS each accept 120 requests per minute per IP address; above that they answer 429. Honor the cache lifetimes above rather than fetching them on every sign-in.

Claims in the id_token

The id_token is a JWT signed with RS256. Decoded payload:

{
  "iss": "https://hub.xyte.io",
  "sub": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "aud": "YOUR_CLIENT_ID",
  "exp": 1757600000,
  "iat": 1757596400,
  "nonce": "8b1e4c6d",
  "email": "[email protected]",
  "name": "Dana Levi",
  "xyte_tenant_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "xyte_tenant_type": "organization"
}
ClaimScopeValue
issopenidhttps://hub.xyte.io.
subopenidThe user's Xyte id, a UUID string. Stable across sign-ins; use it as the user key.
audopenidYour client_id.
iat, expopenidIssued-at time and expiry (iat plus 3600 seconds).
nonceopenidThe nonce from the authorization request. Present only in the token from the code exchange, not in refreshed tokens.
emailemailThe user's email address.
nameprofileThe user's display name.
xyte_tenant_idopenidId of the End Customer the user signed in to, a UUID string.
xyte_tenant_typeopenidAlways organization.

Verify the id_token

  1. Read kid from the JWT header and pick the matching key from the JWKS.
  2. Verify the RS256 signature.
  3. Check that iss equals https://hub.xyte.io and aud equals your client_id.
  4. Check that exp is in the future, allowing a small clock skew.
  5. On the code exchange, check that nonce equals the value you stored in step 1 of the walkthrough.

Reject the sign-in on any failed check. Use a maintained JWT library rather than hand-rolled verification.

Userinfo

GET /oauth/userinfo HTTP/1.1
Host: hub.xyte.io
Authorization: Bearer xoat_...
{
  "sub": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "email": "[email protected]",
  "name": "Dana Levi",
  "xyte_tenant_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "xyte_tenant_type": "organization"
}

POST /oauth/userinfo is accepted as well (OpenID Connect Core 5.3.1): send the same Authorization header with an application/x-www-form-urlencoded body, which may be empty. The response is identical.

email and name follow the granted scopes. sub and xyte_tenant_id are UUID strings. The endpoint answers for any access token whose authorization requested the openid scope, no matter whether an administrator or an ordinary member signed in; it describes the person who signed in. A token whose authorization did not request openid gets 403 insufficient_scope. Responses carry Cache-Control: no-store.

Revocation

From your application

POST /oauth/revoke follows RFC 7009. It takes the same client authentication and application/x-www-form-urlencoded body as the token endpoint.

ParameterRequiredValue
tokenYesAn access token or refresh token. Every token issued to the same authorization is revoked.
token_type_hintNoAccepted and ignored; the token prefix identifies the type.

Revoking any token revokes every access token, refresh token and pending authorization code issued to the same authorization. The grant itself stays in place; sending the user through /oauth/authorize again issues fresh tokens.

Revocation is token-only: it does not touch the End Customer's approval of your application. That is why re-authorizing works straight away, without asking an administrator again. Withdrawing the approval itself is done by an End Customer administrator under Settings → Connected apps in the Customer Portal.

The endpoint always answers 200 with an empty body, including for unknown, expired and already-revoked tokens and for tokens issued to another client. Only a malformed request (400 invalid_request: token is missing, the body is not application/x-www-form-urlencoded, or client credentials were sent both in the Authorization header and in the body with different values) or bad client credentials (401 invalid_client) fail.

From the Customer Portal

End Customer administrators see connected applications under Settings → Connected apps in the Customer Portal, along with the sign-in policy each one carries, and can revoke them there.

Revoking and approving again is also how an administrator changes that policy — there is no way to edit it in place. Revoking withdraws the approval and kills every token your application holds for that End Customer, whichever of its users obtained them. Its users then land under Requires administrator approval on the consent screen until an administrator approves your application again. Xyte can also disable an application entirely. Disabling revokes all of its grants, and while it is disabled the token and revocation endpoints answer 401 invalid_client. The grants stay revoked after the application is re-enabled, so refreshes fail with invalid_grant and every End Customer has to connect again.

What to do on 401

A 401 from the Organization Core API with WWW-Authenticate: Bearer error="invalid_token" means the access token is expired, revoked or not recognized:

  1. Refresh once with the stored refresh token.
  2. If the refresh fails with invalid_grant, the grant is gone: revoked by the End Customer, by you or by a replay, or expired after 90 days. Delete the stored tokens and ask the user to connect again.
  3. Do not retry the original request in a loop. One refresh attempt per 401 is enough.

Rate limits

EndpointLimit
POST /oauth/token60 requests per minute per IP address, 300 per minute per client_id.
POST /oauth/revoke60 requests per minute per IP address.
GET /oauth/userinfo and POST /oauth/userinfo120 requests per minute per IP address.
GET /.well-known/openid-configuration120 requests per minute per IP address.
GET /oauth/.well-known/jwks.json120 requests per minute per IP address.
Consent screen (browser)30 requests per minute per user.

Requests over the limit get 429 with the JSON body {"error":"Too many requests"} and no Retry-After header; bring your own backoff and retry later. Access tokens last 60 minutes, so a well-behaved integration refreshes each grant about once an hour; do not refresh ahead of need.

Security requirements

  • PKCE with S256 is mandatory on every authorization request. plain is rejected.
  • redirect_uri must match a registered URI byte for byte. Register every variant you use.
  • state is required. Bind it to the browser session and verify it on every callback.
  • Use HTTPS for redirect URIs and for every call to Xyte.
  • Keep client_secret on your server. Never embed it in browser or mobile code.
  • Store refresh tokens encrypted at rest and limit access to the service that needs them.
  • Never put tokens in URLs, query strings or logs. Send them in the Authorization header or the request body only.
  • Verify id_token signatures and claims before trusting them.
  • Revoke tokens when a user disconnects or deletes their account with you.

Errors

Every error object, status code and header is listed in the OAuth2 error reference.