Organizations
Organizations group users into teams or tenants. They exist once for the whole server and are not owned by a client. An application (OAuth client) never sees an organization automatically: the organization has to be enabled for the client, and every user decides per application which of their organizations it may see, similar to GitHub's organization access for OAuth apps.
This page is for application developers and organization admins. Creating organizations, managing members,
roles and the org:* permissions are covered in Self-service;
server-to-server access to organizations is in Backend API.
Concepts
- Organization
-
Id
O...(Oplus 32 alphanumerics), a unique slug (1-63 characters ofa-z,0-9and inner dashes), a name, an optional picture, and two JSON metadata objects (see Organization metadata). - Member
-
A user in the organization, holding zero or more roles. Built-in roles are
role:org-adminandrole:org-member; organizations can define their own roles, whose ids look likeO...:<key>. - Client access
-
The organization has enabled the client. Only enabled organizations are offered to the client.
- Eligible organizations
-
For a given user and client: the user's memberships whose organization enabled the client. For a first-party client (
allOrganizations, see below) all memberships are eligible. - Organization access (per user and app)
-
For each eligible organization the user's decision for this client: granted, hidden, or not decided yet. Only granted organizations reach the client.
- Access request
-
A member asks for an organization that has not enabled the client yet. An organization admin approves or denies it.
- Active organization
-
One granted organization pinned to a sign-in. It is stored on the authorization code or device code and on every token minted from it, including refreshed ones.
Organization usage of a client
Each client has an organization usage setting that controls the active organization:
| Value | Behavior |
|---|---|
|
No active organization. Sending |
|
The user picks an organization or "Personal account" (no active organization). |
|
Every sign-in has an active organization. Users without a granted organization cannot sign in. |
Enabling organizations for a client
-
Set the usage: client settings page
/account/clients/<client-id>, "Organizations" field, or GraphQL:mutation { manage { client(clientId: "C0123456789abcdefghijklmnopqrstuv") { update(organizationUsage: OPTIONAL) { clientId organizationUsage } } } } -
Enable organizations for the client. Any of:
-
An organization admin (permission
org:access) opens/account/organizations/<slug>/accessand adds the client id under "Allow client", or approves a pending request there. GraphQL:mutation { manage { organization(organization: "acme") { setClientAccess(clientId: "C0123456789abcdefghijklmnopqrstuv", enabled: true) } } } -
A server admin (admin GraphQL, see Administration) replaces the full list of organizations of a client:
orgs { setClientOrganizations(clientId: "C...", organizations: ["acme", "globex"]) }. Requests are approved withorgs { approveOrganizationAccessRequest(clientId, organization) }and denied withorgs { denyOrganizationAccessRequest(clientId, organization, userId) }(omituserIdto deny all requests of that organization for the client). -
A client created by an organization (owner is the organization) is enabled for it automatically.
-
POST /api/v1/organizationson the Backend API enables the calling client for the new organization.
-
-
Add the
organizationsscope to the authorize request if the application wants the list of organizations, and/or let it pin an active organization (next sections).
/account/clients/<client-id>/organizations lists the organizations that enabled a client.
manage { client(clientId) { setAllOrganizations(enabled: true) } }). Then all memberships of a user
are eligible and granted, nothing is enabled per organization, and the user never sees the organization access
checkboxes (the active organization picker still applies). See Backend API.What the user sees
During /authorize (and device code verification) pykrete runs these steps in order:
-
Sign in.
-
Organization step, only if the client uses organizations or the
organizationsscope was requested. One screen with up to three parts:-
"Visible to this app": a checkbox per eligible organization. Organizations the user has not decided on yet are pre-checked. An organization requested with
organization=is checked and locked. -
"Continue as": the active organization picker. "Personal account" is offered unless the usage is
REQUIRED. Only checked organizations can be picked. -
"Not enabled for this app", shown together with the checkboxes: the user's other organizations, each with a "Request access" checkbox. Requests go to the organization's admins.
-
-
Scope consent, if the user has not consented to these scopes yet (or
prompt=consent). With an active organization the heading reads "Requests access to ... in <organization>". -
Redirect back with the code. Organization decisions and access requests are saved only after the consent step; cancelling the organization or consent screen saves nothing and redirects with
error=access_denied.
When the screen appears:
| Part | Shown when |
|---|---|
Organization access checkboxes |
Not a first-party client, and any of: an eligible organization has no decision yet, |
"Continue as" picker |
Usage is not |
With REQUIRED, exactly one granted organization and no access checkboxes needed, that organization is used
without asking.
To let users change which organizations your app sees, send them through /authorize with prompt=consent.
They can also change it with GraphQL (there is no account dashboard page for this yet):
query {
myOrganizations {
applications {
clientId name firstParty
organizations { organization { id slug name } access }
}
}
}
mutation {
myOrganizations {
setOrganizationAccess(clientId: "C0123456789abcdefghijklmnopqrstuv", organizationId: "acme", granted: false)
}
}
access is one of GRANTED, HIDDEN, UNDECIDED, REQUESTABLE, REQUESTED.
myOrganizations { requestOrganizationAccess(clientId, organizationId) } files an access request outside the
sign-in flow.
Listing organizations: the organizations scope
Add organizations to scope. The user consents to it like any other built-in scope ("Organizations: App will
see organizations you share with it, and your roles in them").
GET /authorize?response_type=code
&client_id=C0123456789abcdefghijklmnopqrstuv
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20organizations
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: auth.infra.delta.rocks
The ID token (and /userinfo) then carries orgs, the organizations the user granted to this client, with the
user's role ids in each:
{
"iss": "https://auth.infra.delta.rocks/",
"sub": "U0123456789abcdefghijklmnopqrstuv",
"aud": "C0123456789abcdefghijklmnopqrstuv",
"exp": 1793000000,
"iat": 1792996400,
"orgs": [
{"id": "O0123456789abcdefghijklmnopqrstuv", "slug": "acme", "name": "Acme Inc.", "roles": ["role:org-admin"]},
{"id": "O9876543210zyxwvutsrqponmlkjihgfe", "slug": "globex", "name": "Globex", "roles": ["role:org-member"]}
],
"nonce": "n-0S6_WzA2Mj"
}
orgs is only in the ID token, not in the access token. It works with any organization usage, including
NONE.
Pinning an active organization
With usage OPTIONAL or REQUIRED the active organization comes from the picker, or the client names it with
organization=<id or slug>:
GET /authorize?response_type=code
&client_id=C0123456789abcdefghijklmnopqrstuv
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile
&organization=acme
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: auth.infra.delta.rocks
The organization must be eligible for the user (member, and the organization enabled the client), otherwise the
request fails with access_denied. If the user has not granted it to the app yet, the access screen is shown
with that organization locked on. The picker is skipped.
The device authorization flow (/device-auth) has no organization parameter; the picker is used there.
Both tokens get the active organization:
{
"iss": "https://auth.infra.delta.rocks/",
"sub": "U0123456789abcdefghijklmnopqrstuv",
"aud": "A0123456789abcdefghijklmnopqrstuv",
"exp": 1793000000,
"iat": 1792996400,
"azp": "C0123456789abcdefghijklmnopqrstuv",
"scope": "openid profile projects:read",
"org_id": "O0123456789abcdefghijklmnopqrstuv",
"org_name": "Acme Inc.",
"org_roles": ["role:org-admin"],
"org_slug": "acme"
}
org_id, org_slug, org_name and org_roles appear in the ID token and the access token. Token
introspection reports org_id and org_roles. Without an active organization none of them are present.
What else the active organization changes:
-
Scopes: the user's roles in the active organization grant audience scopes in addition to the user's own roles. Roles the user holds in other organizations do not count. See Scopes and claims.
-
Subject: a client with subject scope "Per organization" (
setSubject(subjectScope: ORGANIZATION, ...), page/account/clients/<client-id>/subject) gets a differentsub(S...) per active organization. Without an active organization the client's normal subject is used. See Scopes and claims. -
Hooks: the claims hook receives the active organization, its metadata and the user's roles.
Switching organizations
The active organization is fixed for the lifetime of the grant, refresh tokens included. To switch, send the user
through /authorize again. If the user already granted the target organization, this works silently:
https://auth.infra.delta.rocks/authorize?response_type=code&client_id=C0123456789abcdefghijklmnopqrstuv&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=openid&organization=globex&prompt=none&state=xyz&code_challenge=...&code_challenge_method=S256
Without organization=, prompt=none never shows the picker: OPTIONAL clients get no active organization,
REQUIRED clients get the only granted organization or an error.
Requiring an organization
Set the usage to REQUIRED. Then:
-
The picker has no "Personal account" option, and the user cannot continue without picking.
-
With one granted organization it is used without asking;
prompt=select_accountforces the picker anyway. -
A user with organizations that are eligible but not granted gets the access screen.
-
A user without any eligible organization is redirected back with
access_denied("no organization available for this client") and can't request access from the sign-in screen. An organization admin has to enable the client first.
Errors
Authorize errors are returned to redirect_uri as error, error_description and state query parameters.
| Situation | error |
error_description |
|---|---|---|
|
|
client does not use organizations |
|
|
user denied the request |
User cancelled, or picked an organization that is not checked |
|
user denied the request |
|
|
no organization available for this client |
|
|
consent required (prompt=none) |
|
|
interaction required (prompt=none) |
Token endpoint: the active organization is no longer available (see below) |
|
organization is no longer available for this grant |
Refresh, revocation and changes
Organization access is re-checked every time the token endpoint issues tokens (code exchange, device code, refresh):
-
The active organization must still be granted: the user is still a member, the organization still enables the client (or the client is first-party), the user has not hidden it from the app, and the client usage is not
NONE. Otherwise the request fails withinvalid_grant; the application has to send the user through/authorizeagain. -
orgsandorg_rolesare recomputed from the current memberships and roles. -
Scopes are re-validated against the user's current roles, including roles in the active organization. A scope the user lost fails the refresh with
invalid_scope.
Removing access does not affect tokens already issued: access tokens stay valid, also on introspection, until they expire (one hour). Ways access is removed:
-
The user hides the organization from the app (
prompt=consent, orsetOrganizationAccess(granted: false)). -
An organization admin revokes the client ("Revoke access" on
/account/organizations/<slug>/access, orsetClientAccess(enabled: false)). -
The user leaves or is removed from the organization. Their access decisions and access requests for it are deleted with the membership.
Organization metadata
Each organization has two JSON objects, each at most 8 KiB:
publicMetadata |
privateMetadata |
|
|---|---|---|
Write |
Members with |
Server admins only ( |
Read |
Manage API ( |
Admin API, Backend API, claims hook |
Neither is copied into tokens. Use the claims hook to turn metadata into claims. An update replaces the whole object:
mutation SetNetbirdDomain($m: JSON!) {
orgs {
updateOrganization(organization: "acme", privateMetadata: $m) { id privateMetadata }
}
}
{"m": {"netbird": {"domain": "acme.netbird.example.com"}}}
Users have the same pair (orgs { userMetadata(userId) }, orgs { setUserMetadata(userId, publicMetadata,
privateMetadata) }, admin only), also passed to the claims hook.
Invitations
New members join through invitations. Members with org:members create them on
/account/organizations/<slug>/invitations (link or email) and /account/organizations/<slug>/members
(existing user); they can only hand out roles they may grant. GraphQL:
mutation {
manage {
organization(organization: "acme") {
invite(email: "alice@example.org", roles: ["role:org-member"], expiresIn: 604800) {
url
invitation { id email roles expiresAt }
}
}
}
}
Server admins use orgs { createOrganizationInvitation(organization, email, user, roles, expiresIn) }.
An invitation is a link https://auth.infra.delta.rocks/invite#<code>. The code is 40 random alphanumerics, sits in
the URL fragment (so it does not reach server logs), and only its SHA-256 is stored. The link is returned once,
as url.
| Kind | Who can accept |
|---|---|
Link only (no |
Anyone who has the link and signs in. |
|
An account with that email verified (case-insensitive). The server emails the link (see Email hooks); if rendering or sending fails, the invitation is deleted and creation fails. |
|
Only that user, through the link or under "Invitations" on their |
Rules:
-
expiresInis in seconds and must be positive; default 7 days (604800). -
Redeeming adds the membership and the invitation's roles (existing roles are kept) and deletes the invitation, so every link works once.
-
Opening the link asks the user to sign in (unless already signed in), then shows "You joined <organization>", or one of: "Invitation is invalid or has expired", "This invitation is for another account", "This invitation was sent to an email address that is not verified on your account".
-
Pending invitations are listed on the invitations page and can be revoked there (
revokeInvitation(id)).
NetBird
NetBird can use pykrete as its external OIDC provider, with one NetBird account per pykrete
organization. The default claims hook (freshfront/routes/hooks/claims.ts) implements the
mapping.
What NetBird expects
-
A JWT access token whose
audis NetBird's configured auth audience, with the user id insub. -
Account grouping claims, namespaced by the auth audience:
<audience>/wt_account_domainand<audience>/wt_account_domain_category. Users with the same domain and categoryprivateshare one NetBird account. -
Optionally a groups claim for NetBird's JWT group sync.
-
NetBird ties each
subto exactly one account.
How pykrete maps it
For tokens issued to one of the NetBird clients with an active organization, the hook adds to both the ID token and the access token:
| Claim | Value |
|---|---|
|
The organization's |
|
|
|
The user's role ids in the active organization (for example |
Trailing slashes of NETBIRD_AUTH_AUDIENCE are stripped before the / is added. No active organization, client
not listed, NETBIRD_AUTH_AUDIENCE unset, or no domain (no metadata and no suffix): the hook adds nothing.
wt_account_id is not used, since it cannot place new users into an account.
Example access token:
{
"iss": "https://auth.infra.delta.rocks/",
"sub": "S0123456789abcdefghijklmnopqrstuv",
"aud": "A0123456789abcdefghijklmnopqrstuv",
"exp": 1793000000,
"iat": 1792996400,
"azp": "C0123456789abcdefghijklmnopqrstuv",
"scope": "openid profile email",
"A0123456789abcdefghijklmnopqrstuv/wt_account_domain": "o0123456789abcdefghijklmnopqrstuv.netbird.example.com",
"A0123456789abcdefghijklmnopqrstuv/wt_account_domain_category": "private",
"groups": ["role:org-admin"],
"org_id": "O0123456789abcdefghijklmnopqrstuv",
"org_name": "Acme Inc.",
"org_roles": ["role:org-admin"],
"org_slug": "acme"
}
Configuration
-
Create an audience for NetBird. Its id (
A...) is what goes into the token'saud; use it as NetBird's auth audience and asNETBIRD_AUTH_AUDIENCE. -
Create the NetBird clients (dashboard, and the CLI client if separate) with the grant types, redirect URIs and scopes NetBird needs.
-
On each NetBird client:
-
organization usage
REQUIRED, so every token has an active organization; -
subject type public, subject scope "Per organization" (
setSubject(subjectType: PUBLIC, subjectScope: ORGANIZATION)), so a person in two organizations is two NetBird users. With public subjects thesubis the same across all NetBird clients.
-
-
Enable the NetBird clients for each organization that should get a NetBird account (see Enabling organizations for a client).
-
Set the environment of the freshfront process and restart it (values are read at startup):
Variable Meaning HOOKS_SECRETMust equal
frontend.hooks_secretof the backend; without it no hook runs. See HTTP hooks.NETBIRD_CLIENT_IDSComma-separated client ids of the NetBird clients. Other clients get no NetBird claims.
NETBIRD_AUTH_AUDIENCENetBird's auth audience, the claim prefix.
NETBIRD_DOMAIN_SUFFIXOptional. Domain suffix for organizations without
privateMetadata.netbird.domain, for examplenetbird.example.com. -
Optionally pin a domain per organization in
privateMetadata(example in Organization metadata). -
In NetBird, point the OIDC configuration endpoint at
https://auth.infra.delta.rocks/.well-known/openid-configuration. Where NetBird asks for the issuer, use exactlyhttps://auth.infra.delta.rocks/, including the trailing slash: it is compared with the tokens'iss. -
In NetBird, enable JWT group sync with claim name
groupsif you want role-based groups.
Caveats
-
Treat the domain as permanent. Changing
privateMetadata.netbird.domainorNETBIRD_DOMAIN_SUFFIXlater changes the claim for every user of that organization, and NetBird uses it to place users into accounts. -
NetBird assigns its own user roles (for example who owns the account); pykrete roles only arrive as
groupsvalues, as role ids rather than names. -
"Personal account" sign-ins carry no grouping claims; with
REQUIREDthey cannot happen. -
A user in several organizations picks one at sign-in. Silent re-authentication (
prompt=none) withoutorganization=fails withinteraction_requiredfor such users; refresh tokens keep the pinned organization. -
Changing a client's subject settings after tokens were issued changes every
sub(the API asks forforce: true); set them before the first sign-in. -
NetBird's IdP manager integration is not supported. Run NetBird without an IdP manager.