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
| Category | Examples | Login button text |
|---|---|---|
oauth | Google, Apple, GitHub | "Sign in with Google" |
wallet | MetaMask, WalletConnect | "Connect Wallet" |
social | Telegram, Discord | "Sign in with Telegram" |
banking | BankID, Monobank, Sber ID | "Sign in with BankID" |
enterprise | Active Directory, LDAP, SAML 2.0, OIDC Federation | "Sign in with Okta" |
custom | Any future adapter via AuthProvider Go interface | configurable |
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",
"linked_at": "2026-01-10T08:00:00Z"
}
]
Link a new provider
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.
Unlink a provider
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.
| Scenario | Behaviour |
|---|---|
| First login (0 tenants) | Tenant + Owner created. User has email: null. Flag email_required: true set. |
| Post-login prompt | UI shows "Add an email for account security" on every login until the email is added. The user can skip. |
| Without email | Password reset impossible. Invite impossible. Recovery codes are the only account-recovery path. |
| Email added later | PATCH /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
| Scenario | HTTP | type URI suffix |
|---|---|---|
| Unknown provider ID | 404 | not-found |
| OAuth state mismatch (CSRF) | 400 | validation-error |
| Provider already linked | 409 | conflict |
| Cannot unlink last provider | 400 | validation-error |
| ExternalId already bound to another user | 409 | conflict |