Skip to main content

Auth Providers

The IAM service ships a pluggable provider system. Every login method beyond email + password is an adapter that returns a UserIdentity struct. The kernel takes it from there — creates or finds the user, resolves the tenant, issues the JWT, and applies RBAC. Adapters have no access to the database, JWT internals, or data from other tenants.

Email + password authentication is the built-in protocol and cannot be disabled. Everything else is installed via adapter.


Provider Categories

CategoryExamplesLogin button text
oauthGoogle, Apple, GitHub"Sign in with Google"
walletMetaMask, WalletConnect"Connect Wallet"
socialTelegram, Discord"Sign in with Telegram"
bankingBankID, Monobank, Sber ID"Sign in with BankID"
enterpriseActive Directory, LDAP, SAML 2.0, OIDC Federation"Sign in with Okta"
customAny future adapter via AuthProvider Go interfaceconfigurable

Adapter Interface

Each adapter implements the AuthProvider Go interface (internal/provider):

// services/iam/internal/provider/provider.go
type AuthProvider interface {
// ID returns the unique slug used in endpoints, e.g. "google"
ID() string
// Name returns the human-readable label shown on the login button
Name() string
// Type returns the adapter category: oauth | wallet | social | banking | enterprise | custom
Type() string
// BeginAuth returns the redirect URL the user is sent to
BeginAuth(ctx context.Context, tenantID string) (redirectURL string, state string, err error)
// CompleteAuth handles the provider callback and returns a UserIdentity
CompleteAuth(ctx context.Context, callbackParams map[string]string) (UserIdentity, error)
}

type UserIdentity struct {
ExternalID string
Email string // nullable — wallet providers may not return email
Name string
AvatarURL string
ProviderID string
AccessToken string // provider access token — not stored
}

The kernel never stores the provider's AccessToken. It uses it only to fetch user profile data during CompleteAuth, then discards it.


Registration via Manifest

A module registers its adapter by declaring authProviders in module.manifest.json:

{
"name": "@acme/metamask-provider",
"version": "1.0.0",
"authProviders": [
{
"id": "metamask",
"name": "MetaMask",
"type": "wallet",
"icon_url": "https://cdn.acme.io/metamask.svg"
}
]
}

On installation, the Module Registry validates the manifest and calls POST /api/v1/auth/providers/register internally. The login page and Settings → Authentication automatically show the new provider button — no frontend changes are required.


OAuth Redirect Flow

1. Client calls POST /auth/login/:providerId
→ IAM calls adapter.BeginAuth() → returns redirectURL + state
→ IAM stores state in Valkey (TTL 10 min, CSRF protection)
→ IAM responds: 302 Found, Location: redirectURL

2. User authenticates with the provider (Google, etc.)

3. Provider redirects to GET /auth/callback/:providerId?code=xxx&state=yyy
→ IAM verifies state from Valkey (CSRF check)
→ IAM calls adapter.CompleteAuth(callbackParams)
→ Returns UserIdentity

4. IAM resolves user (create or find by externalId + providerId)
5. IAM resolves tenant (see Multi-Tenant Login Flow)
6. IAM issues JWT pair → client receives tokens

Initiate OAuth Login

POST https://api.septemcore.com/v1/auth/login/google

Response 302 Found:

Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&state=abc123...

OAuth Callback

GET https://api.septemcore.com/v1/auth/callback/google?code=4/P7q7W91&state=abc123

On success, redirects to the frontend with the token pair in the URL fragment (SPA) or sets the HttpOnly refresh token cookie and returns the access token in the body (server-rendered apps).


List Installed Providers

GET https://api.septemcore.com/v1/auth/providers
[
{
"id": "google",
"name": "Google",
"type": "oauth",
"icon_url": "https://cdn.septemcore.com/icons/google.svg",
"enabled": true
},
{
"id": "metamask",
"name": "MetaMask",
"type": "wallet",
"icon_url": "https://cdn.acme.io/metamask.svg",
"enabled": true
}
]

Provider Linking Per User

Auth providers are bound to the user, not the tenant. One Google account (externalId) maps to one userId globally. The same user can belong to multiple tenants — one Google login gives access to all of them.

List linked providers

GET https://api.septemcore.com/v1/users/:id/providers
Authorization: Bearer <access_token>
[
{
"id": "link_01j9p5...",
"provider_id": "google",
"display_name": "Google — [email protected]",
"linked_at": "2026-01-10T08:00:00Z"
}
]
POST https://api.septemcore.com/v1/users/:id/providers/:providerId
Authorization: Bearer <access_token>

The IAM service initiates the provider's OAuth flow. On completion, the provider is linked to the user's account and appears in Settings → Security → Linked Accounts.

DELETE https://api.septemcore.com/v1/users/:id/providers/:providerId
Authorization: Bearer <access_token>

Constraint: A user must always have at least one login method. If providerId is the user's only provider, the request returns 400 Bad Request:

{
"type": "https://api.septemcore.com/problems/validation-error",
"status": 400,
"detail": "Cannot unlink the last authentication provider. Set a password first."
}

Wallet Providers — No Email

Wallet providers (MetaMask, WalletConnect) authenticate via cryptographic signature and do not return an email address.

ScenarioBehaviour
First login (0 tenants)Tenant + Owner created. User has email: null. Flag email_required: true set.
Post-login promptUI shows "Add an email for account security" on every login until the email is added. The user can skip.
Without emailPassword reset impossible. Invite impossible. Recovery codes are the only account-recovery path.
Email added laterPATCH /users/:id + POST /auth/verify-email clears email_required. All features unlocked.

SDK

import { useAuth } from '@platform/sdk-auth';

const { user, listProviders, registerProvider } = useAuth();

// List providers configured for this tenant
const providers = await listProviders();
// [{ id: 'google', name: 'Google', type: 'oauth', ... }]

// Register a new OAuth provider (admin-only)
await registerProvider({
id: 'github',
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
type: 'oauth',
});

Error Reference

ScenarioHTTPtype URI suffix
Unknown provider ID404not-found
OAuth state mismatch (CSRF)400validation-error
Provider already linked409conflict
Cannot unlink last provider400validation-error
ExternalId already bound to another user409conflict