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... (O plus 32 alphanumerics), a unique slug (1-63 characters of a-z, 0-9 and 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-admin and role:org-member; organizations can define their own roles, whose ids look like O...:<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

NONE (default)

No active organization. Sending organization= is an error. The organizations scope still works and lists the granted organizations.

OPTIONAL

The user picks an organization or "Personal account" (no active organization).

REQUIRED

Every sign-in has an active organization. Users without a granted organization cannot sign in.

Enabling organizations for a client

  1. Set the usage: client settings page /account/clients/<client-id>, "Organizations" field, or GraphQL:

    mutation {
      manage {
        client(clientId: "C0123456789abcdefghijklmnopqrstuv") {
          update(organizationUsage: OPTIONAL) { clientId organizationUsage }
        }
      }
    }
  2. Enable organizations for the client. Any of:

    • An organization admin (permission org:access) opens /account/organizations/<slug>/access and 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 with orgs { approveOrganizationAccessRequest(clientId, organization) } and denied with orgs { denyOrganizationAccessRequest(clientId, organization, userId) } (omit userId to 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/organizations on the Backend API enables the calling client for the new organization.

  3. Add the organizations scope 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.

Tip
First-party applications can skip all of this. A server admin enables "All organizations (first-party)" on the 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:

  1. Sign in.

  2. Organization step, only if the client uses organizations or the organizations scope 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.

  3. 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>".

  4. 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, prompt=consent, the organization= organization is not granted yet, or the usage is REQUIRED and nothing is granted yet (but something is eligible). Never with prompt=none.

"Continue as" picker

Usage is not NONE, no organization= parameter, and: for OPTIONAL the user has a granted organization (or the access checkboxes are shown); for REQUIRED more than one organization is granted, the access checkboxes are shown, or prompt=select_account was sent.

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 different sub (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_account forces 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

organization= sent, client usage is NONE

invalid_request

client does not use organizations

organization= is not eligible for the user

access_denied

user denied the request

User cancelled, or picked an organization that is not checked

access_denied

user denied the request

REQUIRED and no organization can be used

access_denied

no organization available for this client

prompt=none, and the access screen would be needed (requested organization not granted yet, or REQUIRED with eligible but no granted organizations)

consent_required

consent required (prompt=none)

prompt=none, REQUIRED, several granted organizations, no organization=

interaction_required

interaction required (prompt=none)

Token endpoint: the active organization is no longer available (see below)

invalid_grant

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 with invalid_grant; the application has to send the user through /authorize again.

  • orgs and org_roles are 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, or setOrganizationAccess(granted: false)).

  • An organization admin revokes the client ("Revoke access" on /account/organizations/<slug>/access, or setClientAccess(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 org:manage (manage { organization(...) { update(publicMetadata: ...) } }), server admins, Backend API POST /organizations

Server admins only (orgs { updateOrganization(organization, privateMetadata) }), Backend API POST /organizations

Read

Manage API (publicMetadata), admin API, Backend API, claims hook

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 email, no user)

Anyone who has the link and signs in.

email

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.

user (username or user id)

Only that user, through the link or under "Invitations" on their /account/organizations page (accept or decline). The user must not be a member already and must not have another pending invitation to the organization. Cannot be combined with email.

Rules:

  • expiresIn is 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)).

Note
Without email configured (see Email), invitations are not emailed. Copy the link shown after creating the invitation and send it yourself.

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 aud is NetBird's configured auth audience, with the user id in sub.

  • Account grouping claims, namespaced by the auth audience: <audience>/wt_account_domain and <audience>/wt_account_domain_category. Users with the same domain and category private share one NetBird account.

  • Optionally a groups claim for NetBird's JWT group sync.

  • NetBird ties each sub to exactly one account.

Note
This describes NetBird's claim handling as the hook assumes it. Check it against the NetBird version you run.

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

<NETBIRD_AUTH_AUDIENCE>/wt_account_domain

The organization's privateMetadata.netbird.domain if it is a string, else <organization id in lowercase>.<NETBIRD_DOMAIN_SUFFIX>

<NETBIRD_AUTH_AUDIENCE>/wt_account_domain_category

private

groups

The user's role ids in the active organization (for example role:org-admin, O...:netbird-admins)

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

  1. Create an audience for NetBird. Its id (A...) is what goes into the token's aud; use it as NetBird's auth audience and as NETBIRD_AUTH_AUDIENCE.

  2. Create the NetBird clients (dashboard, and the CLI client if separate) with the grant types, redirect URIs and scopes NetBird needs.

  3. 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 the sub is the same across all NetBird clients.

  4. Enable the NetBird clients for each organization that should get a NetBird account (see Enabling organizations for a client).

  5. Set the environment of the freshfront process and restart it (values are read at startup):

    Variable Meaning

    HOOKS_SECRET

    Must equal frontend.hooks_secret of the backend; without it no hook runs. See HTTP hooks.

    NETBIRD_CLIENT_IDS

    Comma-separated client ids of the NetBird clients. Other clients get no NetBird claims.

    NETBIRD_AUTH_AUDIENCE

    NetBird's auth audience, the claim prefix.

    NETBIRD_DOMAIN_SUFFIX

    Optional. Domain suffix for organizations without privateMetadata.netbird.domain, for example netbird.example.com.

  6. Optionally pin a domain per organization in privateMetadata (example in Organization metadata).

  7. In NetBird, point the OIDC configuration endpoint at https://auth.infra.delta.rocks/.well-known/openid-configuration. Where NetBird asks for the issuer, use exactly https://auth.infra.delta.rocks/, including the trailing slash: it is compared with the tokens' iss.

  8. In NetBird, enable JWT group sync with claim name groups if you want role-based groups.

Caveats

  • Treat the domain as permanent. Changing privateMetadata.netbird.domain or NETBIRD_DOMAIN_SUFFIX later 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 groups values, as role ids rather than names.

  • "Personal account" sign-ins carry no grouping claims; with REQUIRED they cannot happen.

  • A user in several organizations picks one at sign-in. Silent re-authentication (prompt=none) without organization= fails with interaction_required for such users; refresh tokens keep the pinned organization.

  • Changing a client's subject settings after tokens were issued changes every sub (the API asks for force: true); set them before the first sign-in.

  • NetBird's IdP manager integration is not supported. Run NetBird without an IdP manager.