Skip to content
rw3iss Auth

App registration

How a new app gets wired into the rw3iss auth system. Every consumer — backend service, frontend SPA, mobile client, or internal tool — goes through this same flow, once.

For the broader picture (token lifecycle, multi-tenant model) see the auth-server architecture. This doc covers only the onboarding contract.


TL;DR

1. system_admin runs `ven apps create` (or POSTs to /admin/apps)
2. New app sets two env vars: RW3ISS_APP_CODE + JWT_ACCESS_SECRET
3. New app calls /auth/login with app_code; gets back a JWT scoped to that app

Three steps total. Step 1 is one-time per app. Steps 2-3 are normal config + runtime.

Step 1 is easiest via the ven CLI — see the Apps command for the interactive form. The raw REST equivalent is documented in §2 below for reference.


1. Concepts in one paragraph each

App. A user-facing consumer of rw3iss auth — anything that initiates logins or holds access tokens. Identified by a stable code (e.g. marketplace-v2, release-manager). One row in the apps table. Owns its redirect-URL allowlist, opt-in/out of auto-grant, and the set of permission services it consumes. Required before tokens can be minted for it.

Service. A backend that owns and declares a slice of the permission catalog. Identified by a stable string (e.g. auction-api, billing). A service self-registers its permissions at boot via POST /admin/permissions/register; auth-server reconciles (upserts new, prunes removed). Most apps are 1:1 with a service of the same code. Pure-frontend apps may have no service at all and still work fine — they just don’t carry custom permissions.

user_apps membership. A row per (user, app) pair indicating the user is allowed into that app. Created either automatically on first login (auto_grant_on_signup: true) or explicitly by an admin (POST /admin/users/{userId}/apps/{appId}).


2. The registration step (one-time per app)

A system_admin creates the app row. Two paths — the CLI is the canonical one; the REST API is the underlying surface.

Path A — ven apps create (canonical)

Install once: npm install -g github:rw3iss/cli.

Terminal window
ven login # sign in as a system_admin user
ven apps create \
--code marketplace-v2 \
--name "Marketplace v2" \
--description "Public marketplace, browser SPA" \
--redirect-url https://marketplace-v2.ryanweiss.net/auth/callback \
--auth-method password --auth-method google \
--auto-grant

Or run ven apps and pick + Register a new app for the interactive form (validates every field, prompts for permissions, prints the new app’s id + code on success).

Path B — direct REST

  1. Get a system_admin access token. Log in via the demo at demo.auth.ryanweiss.net as a user with the system_admin role (today: demotest@ryanweiss.net). Copy the access token from the browser devtools (any authed request → Authorization header), or log in over the REST API and capture the token from the response.

  2. POST to /admin/apps with the new app’s config:

POST /admin/apps
Authorization: Bearer <system-admin-token>
Content-Type: application/json
{
"code": "marketplace-v2",
"name": "Marketplace v2",
"description": "Public marketplace, browser SPA",
"allowed_redirect_urls": ["https://marketplace-v2.ryanweiss.net/auth/callback"],
"service_codes": ["marketplace-v2"], // optional; defaults to [code]
"auto_grant_on_signup": true, // optional; default false
"registration_namespace": "default", // optional default pool; default "default"
"read_namespaces": [], // optional other pools (login)
"default_organization_id": "<org id>", // optional; org new users auto-join
"default_role_code": "seller", // optional; org role for that membership (default org_member)
"linked_app_codes": ["rw3iss-marketplace"], // optional; extra apps to also grant
"status": "active"
}

Quick curl form:

Terminal window
curl -X POST https://auth.ryanweiss.net/api/v1/admin/apps \
-H "Authorization: Bearer $RW3ISS_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"code": "marketplace-v2",
"name": "Marketplace v2",
"allowed_redirect_urls": ["https://marketplace-v2.ryanweiss.net/auth/callback"],
"auto_grant_on_signup": true
}'

Response:

{
"id": "9c1f...",
"code": "marketplace-v2",
"status": "active",
"created_at": "..."
}

That’s the whole platform-side setup.

Editing after creation: PATCH /admin/apps/{appId} accepts every field except the immutable code — including (since 2026-06) the registration-policy fields frontend_url, allowed_email_domains, allowed_auth_methods and default_organization_id ("" clears the URL / org back to NULL), plus default_role_code + linked_app_codes. Mind frontend_url’s blast radius: account emails (verify, reset, magic-link, invitations) link to that origin.

Auto-provisioning: default role + linked apps

With auto_grant_on_signup: true, the first time a user touches the app (register, first login, or JIT migration) the server provisions a full entitlement set, idempotently:

  • a user_apps membership for this app and each linked_app_codes entry (unknown codes are skipped with a warning, never fatal);
  • when default_organization_id is set, an org membership with default_role_code — which must be an org-scoped role (platform roles system_admin / super_admin / base_user are refused → org_member).

default_role_code is validated at config time: POST/PATCH /admin/apps rejects (400) a role that is set without a default_organization_id, or that doesn’t exist / isn’t org-scoped — so a typo surfaces immediately instead of silently degrading to org_member at login. linked_app_codes are not existence-checked at config time (an app may be linked before its target exists); unknown codes are skipped during provisioning.

Per-request override. A client may pass role_code and/or linked_app_codes in the login or register body to override the app’s defaults for that request (e.g. register a buyer vs. a seller through the same app); the app config is the fallback. role_code is re-validated server-side as an org-scoped role, so a client can never escalate to a platform role:

POST /auth/login
{ "email": "...", "password": "...", "app_code": "globalsku",
"role_code": "buyer", "linked_app_codes": ["rw3iss-marketplace"] }

Webhooks

Apps can declare outbound webhooks fired on user.registered — new-user creation through the app (never plain logins):

{
"webhooks": [
{ "name": "Slack #signups",
"url": "https://hooks.slack.com/services/T…/B…/x…",
"events": ["user.registered"],
"enabled": true }
]
}

Settable at create and PATCH. Delivery is async + best-effort (3 attempts, 5s timeout; registration never blocks on a webhook). The JSON envelope carries the app, the new user (with pools), any org, the complete registration body — password redacted, extra client fields (referral codes, campaign tags, …) passed through verbatim — and request context, plus an X-rw3iss-Event header. URLs under hooks.slack.com automatically receive a Slack-formatted {"text": …} summary instead of the raw envelope, so a Slack incoming-webhook URL works as-is. The demo’s application editor shows the exact sample payload under Webhooks → Sample payload.

What gets validated at registration

  • code is unique, kebab-case, ≤100 chars.
  • allowed_redirect_urls is non-empty in production mode (refused by config validation).
  • service_codes defaults to [code] if omitted; entries are stable strings, not required to point at a service that exists yet (a service can register later).
  • registration_namespace / read_namespaces entries are lower-case [a-z0-9_-], ≤100 chars. Omit both for the single shared default pool.

User pools (optional)

By default every user lives in one global pool (default) and an app reads + writes that pool. Apps that want segregated identity set two fields (email is unique per pool, so the same address can be a distinct user in two pools):

  • registration_namespace — the default pool (registration). New users registered through this app get it as their home pool (users.namespace). Single value; omit for default.
  • read_namespaces — the other pools (login). Login matches an existing user by email across the default pool plus this set (home pool or membership tag), so an account already in one of those pools is reused, not duplicated. New registrants are also tagged into these pools (user_namespaces rows), so pool-scoped apps can address exactly that cohort.

Worked example — an app that registers into the shared pool but also recognizes users from two sibling apps:

{
"code": "claimleo",
"registration_namespace": "default",
"read_namespaces": ["claimleo", "wristleo"]
}

New emails → home pool default, tagged claimleo + wristleo; existing claimleo- or wristleo-pool users authenticate without re-registering; a user whose home pool isn’t in the login set simply doesn’t exist to this app. The access token carries a namespace claim for non-default home pools. Full semantics: the auth-server’s docs/USER_POOLS.md.

Pools are administered at runtime (system_admin only): GET /admin/namespaces lists every pool with user counts and the apps referencing it (pools with zero users are config-only), and /admin/users/{id}/namespace(s) moves a user’s default pool (409 if the email already exists in the target pool) or adds/removes tag pools. The demo’s Applications & Services → User pools tab and the admin user page expose all of this.


3. The consumer’s side (per environment)

The new app sets two env vars and calls the API. The shape of “calling the API” depends on the app type.

Required env

RW3ISS_AUTH_URL=https://auth.ryanweiss.net
RW3ISS_APP_CODE=marketplace-v2
JWT_ACCESS_SECRET=<shared with auth-server, ≥32 chars>

The JWT secret is the same one the auth-server signs with; sharing it lets the consumer validate access tokens locally (HMAC signature) without a network hop per request.

Backend-only API (Node)

import { AuthClientModule } from '@rw3iss/auth-server-nest';
AuthClientModule.forRoot({
authUrl: process.env.RW3ISS_AUTH_URL,
appCode: process.env.RW3ISS_APP_CODE,
jwtSecret: process.env.JWT_ACCESS_SECRET,
});
// In a controller:
@UseGuards(AuthGuard)
@Get('/me/orders')
listOrders(@CurrentUser() user) { ... }

The SDK validates the bearer token locally and asserts claims.app_id matches this app’s code. Any mismatch → 401.

Frontend SPA (Preact / React)

The browser SDK (@rw3iss/auth-client) is the canonical way to integrate. It handles app_code on every login, refresh-token rotation, BroadcastChannel cross-tab sync, and PKCE for SSO. See docs.auth.ryanweiss.net/auth-client/ for the full surface.

Minimal example:

import { createAuthClient } from '@rw3iss/auth-client';
const auth = createAuthClient({
apiBaseUrl: 'https://auth.ryanweiss.net/api/v1',
appCode: 'marketplace-v2',
});
await auth.login({ email, password });

Mobile / server-to-server

Same HTTP contract as the SPA. Until language-specific SDKs exist, consumers do direct REST calls. The wire protocol is stable.


4. What’s in the issued JWT

When a user logs in with app_code: "marketplace-v2", the access token carries:

ClaimValue
app_idthe marketplace-v2 UUID
app_code"marketplace-v2"
uiduser UUID
email, first_name, last_nameuser identity
org_id, org_slugoptional org context
rolesrole codes (e.g. ["base_user"])
permissionsunion of permissions across services in apps.service_codes, plus all core permissions
tvper-user token version (for logout-everywhere — see AUDIT §1.10)
iss, aud, exp, nbf, iat, jtistandard JWT claims

core permissions (auth-server’s own slice — users:read_self, users:update_self, etc.) are always included regardless of which app the token is scoped to. They’re the bedrock catalog every user gets.

If the app’s service_codes list services that haven’t registered any permissions yet, the permissions array is just core + whatever role-based perms the user has. That’s fine — the app simply doesn’t carry custom permissions yet.


5. Adding custom permissions (optional, later)

When the new app wants to introduce its own permissions (e.g. listings:create, bids:place):

  1. The app’s backend calls on boot:
    POST /admin/permissions/register
    Authorization: Bearer <service-account-or-system-admin>
    {
    "service": "marketplace-v2",
    "permissions": [
    { "code": "listings:create", "name": "Create listing", "resource": "listings", "action": "create" },
    { "code": "bids:place", "name": "Place bid", "resource": "bids", "action": "place" }
    ]
    }
  2. Auth reconciles: upserts these, prunes any previously-declared marketplace-v2 permissions not in the list. Idempotent — safe to call every boot.
  3. A system_admin assigns the new permissions to roles via the existing role-permission API.
  4. Users with those roles, logging into marketplace-v2, see the permissions in their JWT.

This step is purely opt-in. Apps that don’t need custom permissions never call this.


6. Granting users access

Two modes, set on the apps row:

  • auto_grant_on_signup: true — every user who logs into the app gets a user_apps row on first attempt. Use for public consumer apps where any rw3iss user can enter.
  • auto_grant_on_signup: false (default) — users must be explicitly granted:
    POST /admin/users/{userId}/apps/{appId}
    Authorization: Bearer <system-admin>
    Use for internal tools and paid-tier apps.

A user can be revoked from one app without affecting their session in others:

DELETE /admin/users/{userId}/apps/{appId}

This sets the user_apps.status to revoked and bumps the user’s token version, so their currently-valid access token for that app stops validating on the next request.


7. What’s required vs. optional, at a glance

StepRequired?WhoWhen
Create the app rowYessystem_admin (POST /admin/apps)Once per app
Set RW3ISS_APP_CODE + JWT_ACCESS_SECRET envYesthe new appPer environment
Pass app_code on /auth/loginYesthe new appEvery login
Register a service catalog (permissions/register)Nothe new app’s backendBoot, only if the app owns custom permissions
Grant users via user_appsNo (if auto_grant_on_signup)system_admin or autoFirst login or explicit grant
Configure SSO providersNosystem_adminOnly if the app accepts third-party login

8. Common pitfalls

  • Forgetting app_code on login. Returns either a 400 (when app_code is required globally) or a token without an app_id claim, depending on config. Downstream services that enforce claims.app_id == self.app_id will reject the token.
  • Redirect URL mismatch. https://marketplace-v2.ryanweiss.net/auth/callback registered, request goes to https://marketplace-v2.ryanweiss.net/auth/callback/ (trailing slash) — strict match, fails. Use the trailing * wildcard if you need a path prefix.
  • JWT secret drift. Auth-server rotates JWT_ACCESS_SECRET, the consumer doesn’t pick up the change. Validation fails for every request. Rotations need to be coordinated; the dual-secret rotation feature on the Phase C roadmap will fix this.
  • Stale service_codes after splitting a backend. If marketplace-v2’s billing logic gets pulled into a separate billing service, update apps.service_codes to ["marketplace-v2", "billing"]. Otherwise marketplace-v2 users lose access to billing permissions in their JWT.

9. References