Authentication and authorization
This guide explains how to authenticate to the Osyra API and how Osyra authorizes the calls you make once you are authenticated. It distinguishes implemented protocol support from deployment activation: a route can exist in code while a production feature flag or required KMS integration remains off.
Two services do the work, and the split matters for understanding errors and latency:
- IAM (the identity service) owns all identity data and every authentication decision. It issues your tokens, runs the OAuth grants, and answers permission checks. It is reachable only over gRPC inside the platform.
- Edge is the single public entry point, exposed through two
deliberately separate trust origins.
auth.osyra.aiserves OAuth and discovery;api.osyra.aiserves protected product APIs. Edge terminates TLS, exchanges API keys through IAM, proxies OAuth requests to IAM, validates the resulting JWT sessions via JWKS, and enforces policy before a request reaches a backend.
The identity model: organization, workspace, user
Osyra uses a three-level hierarchy, modelled on the way AWS and Azure structure tenancy:
Organization (the billing and trust boundary; use its Console code)
└─ Workspace (the isolation boundary; use its Console code)
└─ User (the human or service identity; use its IAM ID)- An organization is the top-level unit. It owns one or more workspaces.
- A workspace belongs to exactly one organization. It is the boundary your API keys, sessions, and resources are scoped to.
- A user belongs to a primary workspace and an organization. Most access tokens are valid for a single workspace at a time.
Workspace-scoped access tokens accepted by Edge carry org_id and
workspace_id claims, and authorization decisions compare that workspace with
the resource being touched. A workspace-scoped access token cannot reach
another workspace's resources unless the user's root_scope is
ORGANIZATION. This statement is intentionally limited to access tokens:
refresh, MFA-challenge, reset, and step-up tokens have different schemas and
are not bearer credentials.
Choosing how to authenticate
Pick a credential based on who is calling and whether the integration needs the OpenAI-compatible inference surface or a delegated OAuth identity.
Create an IAM-backed API key in Console. The plaintext appears exactly once; Osyra persists verification material, not the secret. Keys are bound to the owner, organization, and workspace.
SANDBOX: 24-hour lifetime and 100 requests in a rolling 24-hour window.TEST/LIVE: no expiry unless you configure one.- Native protected APIs:
X-Api-Key. - Upstream OpenAI SDK compatibility:
Authorization: Bearer osy_*, accepted only on the exact/v1Chat Completions, streaming, Embeddings, and Models method/path tuples. - Availability: the deployment must enable API-key crypto and project its KMS HMAC key. A visible Console route alone does not prove that activation.
Use an Osyra API key
An exact key has the form
osy_(sandbox|test|live)_<43 base64url characters>. Edge does not authenticate
the secret locally: it sends the presented key to IAM, receives a short-lived
session_type=api_key JWT for the key's bound identity, removes the plaintext
header, and runs the normal JWT, policy, quota, billing, audit, and OME chain.
POST /api/v1/<native-protected-operation> HTTP/1.1
Host: api.osyra.ai
X-Api-Key: <the-key-copied-once-from-console>
Content-Type: application/jsonFor the OpenAI-compatible SDK surface, prefer the native Osyra SDKs: they detect
an exact key and emit X-Api-Key. Upstream OpenAI SDKs always emit Bearer, so
Edge provides the bounded /v1 compatibility exception described above.
Rotate or revoke keys in Console. Listing a key returns metadata such as its prefix, hint, class, timestamps, and status—never the plaintext secret.
Each successful key exchange mints a five-minute session_type=api_key access
JWT. Revoking the key blocks new exchanges immediately, but a JWT already minted
from that key can remain valid until its own expiry (or a separate JWT/session
revocation), so the documented worst-case residual window is five minutes.
OAuth grants and current availability
The edge gateway proxies the standard OAuth POST /oauth/token endpoint to IAM.
The table separates public integration contracts from first-party flows and
feature-gated capabilities.
| grant_type | Intended caller | Declared production status | Returns |
|---|---|---|---|
| client_credentials | Service / backend | Enabled | access_token, expires_in, scope |
| refresh_token | Registered confidential client holding a refresh token | Enabled | Rotated access_token + refresh_token + expires_in |
| urn:ietf:params:oauth:grant-type:token-exchange | Service acting for a user | Enabled | Narrowed, non-refreshable access_token |
| authorization_code | Browser / mobile client with PKCE | Implemented, feature-gated off | access_token and policy-dependent refresh_token |
| urn:ietf:params:oauth:grant-type:device_code | Headless CLI/device | Implemented, feature-gated off | access_token after user approval |
| password, mfa, backup_code, workspace switch | Osyra first-party confidential clients | Enabled internally; not a public integration interface | User/workspace session |
Token exchange (RFC 8693) validates a user's subject token and produces a non-refreshable child token whose lifetime is capped by both the subject's remaining lifetime and 15 minutes. The resulting token is authorized against the user's permissions. Treat it as an advanced delegated flow, not a replacement for authenticating the calling service.
Get a token with client credentials
This is the fastest path for a backend. Send a form-encoded POST to the token endpoint.
curl -s -X POST https://auth.osyra.ai/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$OSYRA_CLIENT_ID" \
-d "client_secret=$OSYRA_CLIENT_SECRET" \
-d "scope=osyra:llm:model:invoke"A successful response is the standard OAuth shape:
{
"access_token": "<access_token — an ES256 JWT, ~800 chars>",
"token_type": "Bearer",
"expires_in": 900,
"scope": "osyra:llm:model:invoke"
}Access tokens are capped at 15 minutes. There is no refresh token for
client_credentials — when the token expires, request another. The scope you
ask for must be a subset of the scopes the client was granted, or IAM rejects
the request.
Log a user in (authorization code with PKCE, when enabled)
Interactive clients use authorization code with PKCE S256. The authorization server owns password entry, SSO, consent, and any MFA challenge; your application never collects an Osyra password or handles an MFA challenge token directly.
The committed production configuration currently leaves this flow disabled. The steps below are the implemented contract for an environment whose operator has explicitly enabled it and passed the authorization-code E2E gate.
-
Generate a PKCE pair in the client: a random
code_verifier(43–128 characters) andcode_challenge = BASE64URL(SHA256(code_verifier)). -
Send the user to
https://auth.osyra.ai/oauth/authorizewithresponse_type=code,code_challenge,code_challenge_method=S256, yourclient_id, and the exact registeredredirect_uri. Osyra completes primary authentication and MFA before redirecting back with a single-use code. No code is issued when authentication, MFA, or consent fails. -
Exchange the code for tokens at
/oauth/token, sending the originalcode_verifieras proof:bash curl -s -X POST https://auth.osyra.ai/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "client_id=$OSYRA_CLIENT_ID" \ -d "code=$AUTH_CODE" \ -d "code_verifier=$CODE_VERIFIER" \ -d "redirect_uri=https://app.example.com/callback"IAM verifies that
SHA256(code_verifier)equals the storedcode_challengeand thatredirect_urimatches, then returns anaccess_token. A rotatingrefresh_tokenis returned only when the registered client policy permits one—for example, a confidential BFF or a sender-constrained public client.
Refresh an access token
Access tokens are short-lived (15 minutes). If your registered client is issued a refresh token, it lasts up to 30 days and rotates on every use. Present the current refresh token to get a new access token:
curl -s -X POST https://auth.osyra.ai/oauth/token \
-u "$OSYRA_CLIENT_ID:$OSYRA_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=$OSYRA_REFRESH_TOKEN"Refresh is a confidential-client operation in the current public contract. Send
the registered client credentials with HTTP Basic authentication (or as a
complete client_id + client_secret form pair); a refresh token by itself is
not sufficient. Public-client refresh remains disabled until its
sender-constrained DPoP path is enabled.
Osyra enforces single-use, rotate-on-use refresh: every refresh issues both a new access token and a new refresh token, and the old refresh token is recorded as spent. Always store the refresh token returned by each call and discard the previous one.
Provision users from your IdP (SCIM 2.0)
The grants above are how a caller gets a token. SCIM is how the users who hold those tokens get created and removed in the first place — automatically, from your enterprise Identity Provider (Okta, Azure AD / Entra ID, Google Workspace) rather than by hand. Osyra implements SCIM 2.0 (RFC 7643 core schema, RFC 7644 protocol) so your IdP can drive the full user lifecycle into a single workspace.
The flow is the same proxy shape as the OAuth endpoints — your IdP talks REST to the edge gateway, which translates to IAM over gRPC:
Your IdP ──REST /api/v1/iam/scim/v2/*──▶ edge gateway ──gRPC──▶ IAM
(provision / deprovision) (protected entry) (identity authority)Authenticate the IdP (OAuth bearer access token)
SCIM's advertised standard scheme is OAuth bearer authentication. A client sends an Osyra OAuth access JWT on every request:
POST /api/v1/iam/scim/v2/Users HTTP/1.1
Host: api.osyra.ai
Authorization: Bearer <oauth-access-token>
Content-Type: application/scim+json- Public OAuth scopes use Osyra Scope Protocol (OSP): grant
osyra:iam:scim:readfor reads,osyra:iam:scim:writefor create/update, andosyra:iam:scim:deletefor deprovisioning (or the reviewedosyra:iam:scim:*aggregate). - It is bound to one organization and one workspace and cannot provision across workspaces — a token minted for one workspace cannot touch another.
- SCIM uses the normal authenticated per-user, per-tier API bucket (currently 1,000/min FREE, 3,000/min PRO, and 10,000/min ENTERPRISE), not a separate per-IdP bucket.
- The three discovery endpoints below are protected by the same credential, authorization, rate-limit, and workspace-context chain. They are not anonymous metadata routes.
The user resource your IdP sends
Osyra maps the standard SCIM User resource onto an Osyra user. Only the fields
below are consumed; anything else your IdP includes is accepted and ignored.
| SCIM field | Maps to | Notes |
|---|---|---|
| userName | User email | Required; stored lower-cased and trimmed. Used as the unique identity. |
| name.givenName | First name | Defaults to Unknown if omitted. |
| name.familyName | Last name | Defaults to User if omitted. |
| active | Account status | true or omitted → active; false → provisioned suspended (see below). |
| emails[] | Email addresses | Carried on the resource; userName is the identity of record. |
A minimal provisioning request looks like this:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "<idp-user-email>",
"name": { "givenName": "<idp-given-name>", "familyName": "<idp-family-name>" },
"emails": [{ "value": "<idp-user-email>", "primary": true }],
"active": true
}Provisioning: what happens on create
When your IdP POSTs a user to /api/v1/iam/scim/v2/Users, IAM:
-
Validates and de-duplicates. A missing
userNameis rejected; auserNamethat already exists in the workspace returns a SCIM409conflict. -
Creates the user passwordless. The user is created with no local password — authentication is delegated to your IdP. This is the key consequence covered below.
-
Assigns a least-privilege default role. Every freshly provisioned user receives the
WORKSPACE_VIEWERrole so it lands with a non-empty permission set on day one. Elevate specific users to operational roles separately, in Osyra, after provisioning. -
Honors
active.active: true(or an omittedactive) means the user is activated immediately — its IdP lifecycle is the verifier of record.active: falseprovisions the account in a suspended state; a lateractive: truereactivates it.
Deprovisioning: removal is a soft delete
In enterprise SSO, "remove the user" means revoke access, not erase history.
Osyra honors that: a SCIM DELETE /api/v1/iam/scim/v2/Users/{id} (or a
PATCH with active: false) suspends the account rather than hard-deleting
it. The user stops being able to sign in, but the record and its audit trail are
preserved.
DELETE /api/v1/iam/scim/v2/Users/$OSYRA_SCIM_USER_ID HTTP/1.1
Host: api.osyra.ai
Authorization: Bearer <oauth-access-token>Listing and updating
GET /api/v1/iam/scim/v2/Userslists the workspace's users with SCIM 1-based pagination (startIndexdefaults to 1,countdefaults to 100 and is capped at 1000).- Filtering supports exactly one form —
userName eq "<idp-user-email>". Any other filter expression is rejected with a SCIM400 invalidFilterrather than silently widened to the full user list. PATCH /api/v1/iam/scim/v2/Users/{id}accepts an RFC 7644urn:ietf:params:scim:api:messages:2.0:PatchOpdocument with atomicadd,replace, andremoveoperations. The bounded V1 settable paths areactive,name.givenName, andname.familyName; unsupported or filter-expression paths fail with400 invalidPath.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op": "replace", "path": "active", "value": false }
]
}Discovery endpoints
SCIM clients negotiate capabilities through three read-only discovery endpoints (RFC 7644 §3.1 endpoint table; §4 service-provider configuration). Point your IdP at these to learn what the server supports before it provisions:
| Endpoint | Returns | Schema URN |
|---|---|---|
| GET /api/v1/iam/scim/v2/ServiceProviderConfig | Which optional SCIM features are supported — PATCH, bulk, filter, change-password, sort, ETag — plus the authentication schemes the server accepts. | urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig |
| GET /api/v1/iam/scim/v2/ResourceTypes | The resource types the server exposes (the User resource and its endpoint/schema). | urn:ietf:params:scim:schemas:core:2.0:ResourceType |
| GET /api/v1/iam/scim/v2/Schemas | The attribute schemas for those resource types (names, types, mutability, uniqueness). | urn:ietf:params:scim:schemas:core:2.0:User |
ServiceProviderConfig is where your IdP reads the authentication scheme —
Osyra advertises OAuth bearer authentication as the scheme for the SCIM
endpoints, matching the bearer token you configured above.
The passwordless consequence
This is the one behavior that surprises people, so it is worth stating plainly:
How the edge validates your token
Once you hold an OAuth access token, you send it as a bearer token on protected
application requests. Native API-key clients instead use X-Api-Key:
POST /graphql HTTP/1.1
Host: api.osyra.ai
Authorization: Bearer eyJhbGciOiJFUzI1Ni…
Content-Type: application/json
{ "query": "{ me { id email } }" }The edge gateway validates the token on every request before any backend sees it. Understanding this sequence explains most authentication errors:
-
Extract the bearer token from the
Authorizationheader. -
Resolve the signing key. Edge fetches IAM's public keys over the private IAM gRPC connection and caches them for 5 minutes by default. It refreshes in the background at 80% of that TTL (4 minutes at the default). IAM signs tokens with ES256 (ECDSA P-256) backed by AWS KMS; Edge accepts ES256 only.
-
Recover from a legitimate rotation, then verify. An unknown
kidtriggers a rate-limited, singleflight IAM-gRPC refetch. Edge then reruns the full ES256 signature, expiry, issuer, and audience validation against the refreshed set. A missing key, failed refetch, or bad signature remains a denial; the refresh never grants trust by itself. -
Validate the standard claims:
exp(not expired),nbf(not before),iss(issuer), andaud(audience). A token whose audience or issuer does not match the gateway's configuration is rejected. -
Check revocation in Redis, by both the token's
jtiand its token family. A logged-out or family-revoked token fails here even if its signature and expiry are still valid. -
Propagate identity to the backend. The gateway lifts claims into request context and injects
OS-*headers so downstream services see who you are.
This is the claim-to-header mapping the gateway applies, so you can correlate a token's claims with what backends receive:
| JWT claim | Header injected to backend |
|---|---|
| sub | OS-User-ID |
| org_id | OS-Org-ID |
| workspace_id | OS-Workspace-ID |
| client_id | OS-Client-ID |
| jti | OS-Token-ID |
| act.sub | OS-Acting-User-ID |
A standard user access token looks like this once decoded (claims abbreviated):
{
"iss": "https://auth.osyra.ai",
"sub": "<iam-user-uuid>",
"aud": "https://api.osyra.ai",
"exp": "<expiry-unix-seconds>",
"iat": "<issued-at-unix-seconds>",
"jti": "<token-uuid>",
"token_type": "access",
"org_id": "<organization-uuid>",
"workspace_id": "<workspace-uuid-from-console>",
"roles": ["DEVELOPER", "WORKSPACE_ADMIN"],
"permissions": ["osyra:iam:workspace:read", "osyra:iam:user:read"]
}Authorization: OUV scopes
Authentication proves who you are. Authorization decides what you may do, and Osyra expresses that with the OSP scope grammar — part of the Osyra Unified Vocabulary (OUV). A scope names an action on a resource in a service:
osyra:<service>:<resource>:<action>Every segment is drawn from a shared registry, so an action and the resource it touches can never drift apart:
<service>is a canonical service token — for exampleiam(identity),llm(the model broker),billing,ome, orpolicy.<resource>is a resource type within that service — for exampleuser,workspace,role, ormodel.<action>is a verb from the closed set:read,write,create,delete,list,invoke,export,import,register,rotate,revoke,verify,dispatch,approve,admin,impersonate.
Some real scopes:
osyra:iam:user:read # read users in IAM
osyra:iam:workspace:create # create a workspace
osyra:llm:model:invoke # invoke a model through the broker
osyra:billing:invoice:read # read billing invoicesWildcards broaden a grant, and you should prefer the narrowest form that works:
osyra:iam:user:* # every action on the user resource in IAM
osyra:iam:* # every resource and action in IAM
osyra:* # platform super-grant (admin)How a scope check actually runs
When you call a protected operation, two layers of authorization apply:
-
Role-based permission check. IAM loads the roles assigned to your identity in the request's workspace, flattens the permissions those roles grant, and checks whether the required permission is present (directly or via a wildcard). Present means allowed; absent means denied. This is the common path and it is workspace-scoped — your roles in one workspace do not carry to another.
-
Policy evaluation (where a policy applies). For AWS-IAM-style policies, IAM evaluates the request against the matching policy statements with a strict precedence: an explicit
Denyalways wins, then an explicitAllow, and otherwise the request is an implicit deny. Default-deny is the rule — nothing is permitted unless something explicitly allows it.
Policies are written against ORN resource names, the sibling grammar to OSP. An ORN names a specific resource so a policy can target it:
orn:osyra:llm:<organization-id>:workspace:<workspace-id>:model/<operator-assigned-model-id>
osyra:llm:model:invoke ⟷ the action permitted on that ORNCommon errors and what they mean
| Code | Meaning | What to do |
|---|---|---|
| OSY-AUTH-1001 | Invalid login credentials | Check email/password; repeated failures lock the account for 30 minutes after 5 attempts. |
| OSY-AUTH-1002 | Invalid client credentials | Verify client_id / client_secret and that the client is ACTIVE. |
| OAuth invalid_grant | Invalid refresh token | The public endpoint intentionally hides whether the token was expired, malformed, or already rotated. If refresh fails, re-authenticate. |
| OSY-AUTH-1004 | Invalid API key | The key is unknown, expired, or revoked. Osyra intentionally does not disclose which condition applies. |
| OSY-AUTH-1005 | Workspace access denied | Your token's workspace_id does not match the target resource's workspace. Get a token for the right workspace. |
| OSY-RATE-2001 | Rate limit exceeded | Back off and retry after the retryAfter seconds in the error. |
| OSY-SERVER-9050 | API-key crypto unavailable | This deployment has not activated the KMS-backed key lifecycle. Use OAuth or contact the deployment owner. |
| OSY-SERVER-9002 | API-key verifier unavailable | Edge could not exchange a presented key through IAM. Retry only after the deployment owner activates or restores the verifier; use OAuth meanwhile. |
The OAuth token endpoint also returns standard OAuth error responses: a bad,
expired, or already-used MFA challenge maps to invalid_grant (HTTP 400), a
missing form parameter to invalid_request (HTTP 400), and IAM being
unavailable to temporarily_unavailable (HTTP 503).
Where to go next
- Send OAuth access tokens as
Authorization: Bearer; send native API keys asX-Api-Key. Never send both. - Scope your OAuth clients to the narrowest set of OSP scopes they need, and
rotate the
client_secretperiodically. - For human integrations, use
authorization_code+ PKCE only after the deployment has explicitly enabled and E2E-proven it. Do not integrate against the first-party password/MFA grant family.