Add External OAuth Provider
Implements external OAuth2/OIDC identity provider integration where Next.js acts as the OAuth client. This is the required approach for Buildpad DaaS applications since Supabase is self-hosted and deployment configuration is not accessible.
When to Use This Skill
Use this skill when:
- Adding SSO/OAuth login to a DaaS application
- Integrating with My Apps, Azure AD, Okta, Auth0, Google, or any OAuth2/OIDC provider
- Self-hosted Supabase without access to deployment configuration
- Need JIT (Just-In-Time) user provisioning from external IDP
Login = auth + role. JIT provisioning alone is not enough in a DaaS app: a user without a role gets deny-all from the permission enforcer, so the integration is not done until Step 6 (default role assignment) is implemented and verified with a brand-new IDP account.
Naming note: "My Apps" here refers to a separate OIDC identity provider — not a Microsoft product. Do not confuse it with Microsoft's "My Apps" portal (
myapplications.microsoft.com, part of Entra ID/Azure AD app assignment). If asked to integrate "SSO via MyApps" or "the IDP from MyApps" with no mention of Azure/Entra ID/Microsoft, treat it as this My Apps IDP and go to Step 0 below — do not default to Azure AD/Entra ID.
My Apps is the only IDP here with a fully automated registration path — see Step 0 below before doing any manual setup.
Architecture Overview
sequenceDiagram
participant Browser
participant NextJS as Next.js API Routes
participant IDP as OAuth Provider (IDP)
participant Supabase as Supabase Auth
Note over Browser,Supabase: Phase 1: OAuth Initiation
Browser->>NextJS: GET /api/auth/oauth/[provider]
NextJS->>NextJS: Generate PKCE (verifier + challenge)
NextJS->>NextJS: Generate state token
NextJS->>NextJS: Encrypt state with AES-256-GCM
NextJS-->>Browser: Set-Cookie: oauth_state (encrypted)
NextJS-->>Browser: 302 Redirect to IDP authorize URL
Note over Browser,Supabase: Phase 2: User Authentication at IDP
Browser->>IDP: Follow redirect to authorize endpoint
IDP->>IDP: User authenticates (login, MFA)
IDP-->>Browser: 302 Redirect to /api/auth/callback?code=xxx&state=xxx
Note over Browser,Supabase: Phase 3: Token Exchange & Validation
Browser->>NextJS: GET /api/auth/callback?code=xxx&state=xxx
NextJS->>NextJS: Decrypt oauth_state from cookie
NextJS->>NextJS: Validate state matches (CSRF protection)
NextJS->>IDP: POST /token (code + code_verifier)
IDP-->>NextJS: { access_token, id_token, refresh_token }
NextJS->>NextJS: Validate tokens (JWKS or decode)
NextJS->>NextJS: Extract user claims from ALL sources
Note over Browser,Supabase: Phase 4: Supabase User Provisioning
NextJS->>Supabase: Admin API: Find user by email
alt User exists
NextJS->>Supabase: Admin API: Update user metadata
else User not found
NextJS->>Supabase: Admin API: Create user
end
NextJS->>Supabase: Assign default DaaS role (daas_user_roles)
NextJS->>Supabase: Admin API: Generate magic link
NextJS->>Supabase: Verify OTP to get session
Supabase-->>NextJS: { access_token, refresh_token }
Note over Browser,Supabase: Phase 5: Session Establishment
NextJS->>NextJS: Set session via supabase.auth.setSession()
NextJS-->>Browser: Set-Cookie (Supabase format) + 302 Redirect to app
Design Decisions
1. Next.js as OAuth Client (Not Supabase Built-in)
Rationale: Self-hosted Supabase deployments don't expose IDP configuration. Next.js handles the full OAuth flow.
| Component | Role |
|---|---|
| Next.js API Routes | OAuth client, token exchange, user provisioning |
| External IDP | User authentication, identity assertion |
| Supabase Auth | User database, session management, JWT issuance |
2. PKCE Required
Rationale: Security best practice. Prevents authorization code interception attacks.
- Use S256 method (SHA-256 hash of code_verifier)
- 32-byte random code_verifier (43 chars base64url)
- code_challenge sent to IDP, code_verifier sent on token exchange
3. State Encryption with AES-256-GCM
Rationale: Prevent state tampering and store sensitive data (code_verifier) securely.
State payload contains:
state: Random CSRF tokencodeVerifier: PKCE verifier (sensitive)provider: OAuth provider namereturnTo: Post-login redirect URLcreatedAt: Timestamp for expiration check
4. Multi-Source Claim Extraction
Critical Learning: Different OAuth providers return user claims in different places:
| Provider | Primary Claim Source |
|---|---|
| Azure AD | id_token |
| id_token | |
| Okta | id_token or access_token |
| Auth0 | id_token |
| Custom IDPs | Often access_token or userinfo endpoint |
Implementation: Check ALL sources in order:
- Decode access_token as JWT (if not opaque)
- Validate id_token (if present)
- Call userinfo endpoint (if configured)
- Merge all claims, later sources override earlier
5. Session via supabase.auth.setSession()
Critical Learning: Manually setting sb-access-token cookies doesn't work. Supabase SSR client uses a specific cookie format.
Solution: Create a Supabase SSR client in the callback route and call setSession():
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => cookies.forEach(c => response.cookies.set(c.name, c.value, c.options)),
},
});
await supabase.auth.setSession({ access_token, refresh_token });
6. Email Claim Detection
Critical Learning: Providers use different claim names for email:
| Claim Name | Providers |
|---|---|
email |
Standard OIDC, Google, Auth0 |
preferred_username |
Azure AD (UPN) |
upn |
Some enterprise IDPs |
mail |
LDAP-style providers |
login |
GitHub |
username |
Some custom IDPs |
Implementation: Check all variants in normalizeUserClaims().
7. Default DaaS Role Assignment on JIT Provisioning (Required)
Critical Learning: findOrCreateUser() creates the Supabase auth user only. In a DaaS application, permissions are resolved from daas_user_roles → access → policies → permissions. A JIT-provisioned user with no role has no policies, and the permission enforcer resolves every request to deny-all — so the first SSO login "succeeds" at the IDP but the user cannot actually use (or even finish logging into) the app.
Solution: Default role assignment is part of this skill, not an optional extra. Two pieces:
- Setup time (you, the agent): resolve the default role's UUID with the DaaS MCP
rolestool. If the app has no suitable non-admin role with policies attached, create one via/create-rbacfirst. Store the UUID in theOAUTH_DEFAULT_ROLE_IDenv var. - Runtime (callback flow): after
findOrCreateUser(), assign the default role to users that have no role assignment yet — see Step 6 below.
Issues Encountered & Solutions
Issue 1: "no_email_claim" Error
Problem: ID token didn't contain email claim.
Root Cause: Provider returned email in access_token (JWT) instead of id_token.
Solution: Decode access_token as JWT and merge claims from all sources:
// Try access_token as JWT
const accessTokenClaims = decodeTokenUnsafe(tokens.access_token);
if (accessTokenClaims) {
allClaims = { ...allClaims, ...accessTokenClaims };
}
Issue 2: Login Loop After OAuth Success
Problem: User authenticated successfully (logs showed "User authenticated") but was redirected back to login page.
Root Cause: Manually set cookies (sb-access-token, sb-refresh-token) were not in the format Supabase SSR middleware expects.
Solution: Use supabase.auth.setSession() from @supabase/ssr client instead of manual cookie setting. The SSR client handles the correct cookie format internally.
Issue 3: No getUserByEmail in Supabase Admin API
Problem: supabase.auth.admin.getUserByEmail() doesn't exist.
Solution: Paginate through listUsers() to find by email:
while (!existingUser) {
const { data } = await supabase.auth.admin.listUsers({ page, perPage: 1000 });
const found = data?.users.find(u => u.email?.toLowerCase() === email.toLowerCase());
if (found) existingUser = found;
if (!data?.users || data.users.length < perPage) break;
page++;
}
Issue 4: SSO Login Succeeds but User Is Denied Everything
Problem: A first-time SSO user authenticated at the IDP and received a Supabase session, but the app rejected them afterwards — empty data, permission errors on every request, or an immediate bounce back to the login page.
Root Cause: JIT provisioning created the auth user but nothing assigned a DaaS role. No daas_user_roles row → no policies → the DaaS permission enforcer denies everything.
Solution: Assign the app's default role right after provisioning (see Step 6). Verify a given user's assignment with:
select role_id from daas_user_roles where user_id = '<auth-user-uuid>';
Step 0: Check Connectors for an Existing IDP First
Do this before Step 2/Step 3 below, regardless of whether you use the CLI or manual path. Buildpad's Connectors system can auto-provision an OIDC client (e.g. for My Apps) before you're even asked to build this feature — if that already happened, the credentials exist and you don't need to ask the user for anything or register a redirect URI yourself.
Call the
get_project_detailMCP tool.Look at the
connectorsarray in the response for astatus: "connected"entry whoseenvVarNamesincludesOAUTH_CLIENT_ID— that's the signal a connector is IDP-shaped (as opposed to a plain API-key connector like Stripe), regardless of itsprovidername. Don't match on provider name or on how the user phrased their request — check the data.If found, use its
envVarsdirectly — they're already the exactOAUTH_*values this skill needs (OAUTH_CLIENT_ID,OAUTH_CLIENT_SECRET,OAUTH_AUTHORIZATION_URL,OAUTH_TOKEN_URL,OAUTH_USERINFO_URL,OAUTH_JWKS_URI,OAUTH_ISSUER). Write them into.env.localfor local dev, and call theamplify_set_env_vars+amplify_redeployMCP tools to push them to the deployed app. Skip Step 3 (redirect URI registration) entirely — the connector already registered the deployed app's/api/auth/callbackURL with the IDP at provision time.Not covered by the connector:
OAUTH_DEFAULT_ROLE_ID(Step 6). The connector only knows about the IDP; the default role lives in this app's DaaS backend. Resolve it via the DaaS MCProlestool yourself and push it alongside the connector env vars — SSO logins fail without it.If no such connector is
connectedyet, tell the user they may be able to enable one from the project's Connectors settings page (auto-provision, one click — e.g. for My Apps) instead of manually registering with an IDP — then fall back to the fully manual Steps 2–3 below only if they want to proceed without it, or are integrating a provider with no Connectors auto-provision option (Azure/Google/Okta/Auth0/other).
CLI Installation (Recommended)
The entire OAuth implementation is registered as the external-oauth lib module in the microbuild-ui CLI registry. Install it with one command:
# Install the external-oauth module (includes all files below)
pnpm cli add-lib external-oauth --cwd .
# Or during bootstrap:
pnpm cli bootstrap --cwd .
# Then install the oauth module separately since it's not included by default:
pnpm cli add-lib external-oauth --cwd .
This copies:
lib/oauth/config.ts— provider configs (Azure, Google, Okta, Auth0, Generic)lib/oauth/pkce.ts— PKCE + AES-256-GCM state encryptionlib/oauth/validate.ts— JWKS token validation + claim normalizationlib/oauth/index.ts— barrel exportlib/supabase/admin.ts— JIT user provisioning via Admin APIapp/api/auth/oauth/[provider]/route.ts— OAuth initiation routeapp/api/auth/callback/route.ts— Enhanced dual-mode callback (replaces basic version)components/auth/OAuthLoginButtons.tsx— Login button component
The CLI module does NOT include default role assignment. After installing, you must still do Step 6 (resolve
OAUTH_DEFAULT_ROLE_ID, addensureDefaultRole(), and call it from the callback) — otherwise JIT-provisioned users are denied everything on first login.
Manual Implementation Steps
Use this when you cannot run the CLI and need to create files from scratch.
Step 1: Install Dependencies
pnpm add jose
Step 2: Add Environment Variables
Skip this if Step 0 already found a connected My Apps connector — use its
envVars instead of the placeholder values below.
# ═══════════════════════════════════════════════════════════════════════
# External OAuth Provider Configuration (Next.js as OAuth Client)
# ═══════════════════════════════════════════════════════════════════════
# Required: OAuth State Encryption Secret — generate with: openssl rand -base64 32
OAUTH_STATE_SECRET=your-32-byte-random-secret-here
# Required: default DaaS role assigned to JIT-provisioned SSO users (Step 6).
# Resolve the UUID via the DaaS MCP `roles` tool; create a role with
# /create-rbac if none exists. Never point this at an admin role.
OAUTH_DEFAULT_ROLE_ID=uuid-of-default-role
# Generic / Custom OIDC Provider
OAUTH_CLIENT_ID=your_client_id
OAUTH_CLIENT_SECRET=your_client_secret
OAUTH_AUTHORIZATION_URL=https://idp.example.com/connect/authorize
OAUTH_TOKEN_URL=https://idp.example.com/connect/token
OAUTH_USERINFO_URL=https://idp.example.com/connect/userinfo
OAUTH_JWKS_URI=https://idp.example.com/.well-known/jwks
OAUTH_ISSUER=https://idp.example.com
OAUTH_SCOPES=openid email profile
# Optional: JSON object of extra authorization params e.g. {"prompt":"select_account"}
# OAUTH_AUTH_PARAMS=
# Azure AD / Entra ID
# AZURE_AD_TENANT_ID=your-tenant-id
# AZURE_AD_CLIENT_ID=your_client_id
# AZURE_AD_CLIENT_SECRET=your_client_secret
# Google
# GOOGLE_CLIENT_ID=your_client_id
# GOOGLE_CLIENT_SECRET=your_client_secret
# Okta
# OKTA_DOMAIN=dev-123456.okta.com
# OKTA_CLIENT_ID=your_client_id
# OKTA_CLIENT_SECRET=your_client_secret
# Auth0
# AUTH0_DOMAIN=myapp.auth0.com
# AUTH0_CLIENT_ID=your_client_id
# AUTH0_CLIENT_SECRET=your_client_secret
Step 3: Register Redirect URI with IDP
Skip this for My Apps if Step 0 found a connected connector — the redirect URI was already registered automatically when the connector was provisioned.
| Setting | Value |
|---|---|
| Redirect URI | https://your-app.com/api/auth/callback |
| Grant Type | Authorization Code |
| PKCE | Required (S256) |
| Scopes | openid email profile (add User.Read for Azure) |
Step 4: Create OAuth Library Files
File structure:
lib/oauth/
├── index.ts # Barrel export
├── config.ts # Provider configurations (all providers)
├── pkce.ts # PKCE (RFC 7636) + AES-256-GCM state encryption
└── validate.ts # JWKS token validation + multi-provider claim normalization
lib/supabase/
└── admin.ts # Admin client + findOrCreateUser + generateUserSession
app/api/auth/
├── oauth/[provider]/route.ts # OAuth initiation (PKCE, state cookie, redirect)
└── callback/route.ts # Dual-mode: external OAuth + Supabase fallback
components/auth/
└── OAuthLoginButtons.tsx # Login button component ('use client')
lib/oauth/config.ts
Defines OAuthProviderConfig interface and per-provider factory functions. All IDP settings come from environment variables. getProviderConfig(provider) dispatches to the right factory. validateProviderEnv(provider) returns missing env vars.
Key providers: generic, azure, google, okta, auth0.
export interface OAuthProviderConfig {
clientId: string;
clientSecret: string;
authorizationUrl: string;
tokenUrl: string;
userInfoUrl?: string;
jwksUri?: string;
issuer?: string;
scopes: string[];
authParams?: Record<string, string>;
}
export type SupportedProvider = 'generic' | 'azure' | 'google' | 'okta' | 'auth0';
export function getProviderConfig(provider: string): OAuthProviderConfig { /* ... */ }
export function validateProviderEnv(provider: string): { valid: boolean; missing: string[] } { /* ... */ }
lib/oauth/pkce.ts
PKCE utilities (RFC 7636) + AES-256-GCM state encryption/decryption.
generateCodeVerifier()— 32 random bytes as base64urlgenerateCodeChallenge(verifier)— SHA-256 hash of verifier, base64urlgenerateState()— 16 random bytes as hex (CSRF token)encryptState(data)— AES-256-GCM encrypt object to base64url stringdecryptState<T>(encrypted)— decrypt and JSON parsecreateOAuthState(provider, returnTo)— creates the fullOAuthStateDataobjectisStateExpired(stateData)— checkscreatedAtagainst 10-minute expiry
export interface OAuthStateData {
state: string; // CSRF token
codeVerifier: string; // PKCE verifier
provider: string;
returnTo: string;
createdAt: number; // ms since epoch
}
lib/oauth/validate.ts
Token validation and claim normalization.
validateIdToken(idToken, config)— uses JWKS if configured, falls back to decode-onlydecodeTokenUnsafe(token)— decode JWT without verification (for access_token claim extraction)normalizeUserClaims(claims)— extractsemail,name,firstName,lastName,picture,providerId
normalizeUserClaims checks all known email claim variants: email, preferred_username, upn, mail, emailAddress, user_email, login, username.
lib/supabase/admin.ts
Admin client singleton + JIT user provisioning.
createSupabaseAdmin()— singleton usingSUPABASE_SERVICE_ROLE_KEYfindOrCreateUser(options)— paginateslistUsers()to find by email (nogetUserByEmailexists!), then creates/updates, generates session via magic link + OTP verificationgenerateUserSession(supabase, user)— usesadmin.generateLink('magiclink')+verifyOtp()to get realaccess_token/refresh_tokenensureDefaultRole(userId)— assignsOAUTH_DEFAULT_ROLE_IDindaas_user_rolesif the user has no role yet (Step 6 — required, or SSO users are denied everything)
export async function findOrCreateUser(options: FindOrCreateUserOptions): Promise<UserSessionResult>
// Returns: { user, session: { access_token, refresh_token, ... }, isNewUser }
app/api/auth/oauth/[provider]/route.ts
export async function GET(request, { params }) {
// 1. Validate provider name
// 2. Validate env vars via validateProviderEnv()
// 3. createOAuthState() + generateCodeChallenge()
// 4. encryptState() → set HttpOnly cookie 'oauth_state' (10 min)
// 5. Build authUrl with PKCE params
// 6. return NextResponse.redirect(authUrl)
}
app/api/auth/callback/route.ts
Dual-mode: detects oauth_state cookie to choose external vs. Supabase flow.
export async function GET(request) {
// External OAuth path (oauth_state cookie present):
// 1. Decrypt + validate state (CSRF check + expiry)
// 2. POST to tokenUrl: code + code_verifier → { access_token, id_token, refresh_token }
// 3. Merge claims from: decodeTokenUnsafe(access_token) + validateIdToken(id_token) + userinfo endpoint
// 4. normalizeUserClaims() → email, name, etc.
// 5. findOrCreateUser() → Supabase user + session
// 6. ensureDefaultRole(user.id) → REQUIRED: assign default DaaS role (Step 6);
// on failure redirect with ?error=role_assignment_failed — do not continue,
// the session would be deny-all anyway
// 7. createServerClient() + supabase.auth.setSession() → sets cookies in correct format
// 8. NextResponse.redirect(oauthState.returnTo)
// Supabase native fallback (no cookie):
// supabase.auth.exchangeCodeForSession(code)
}
Critical: use supabase.auth.setSession() from @supabase/ssr createServerClient — do NOT manually set sb-access-token cookies. The SSR client sets the correct format the middleware recognises.
components/auth/OAuthLoginButtons.tsx
'use client';
// Reads ?error= from URL for error display
// Renders Button per provider, onClick → window.location.href = '/api/auth/oauth/[id]?returnTo=...'
// Hides 'generic' SSO button when specific providers (azure/google) are also shown
export function OAuthLoginButtons({ providers?, returnTo?, showDivider? }) {}
Step 5: Add Login UI
import { OAuthLoginButtons } from '@/components/auth/OAuthLoginButtons';
// In your login page:
<OAuthLoginButtons showDivider />
// Show only specific providers:
<OAuthLoginButtons providers={['azure', 'google']} returnTo="/dashboard" />
Step 6: Assign a Default Role to JIT-Provisioned Users (Required)
Do this for both the CLI and manual paths. Without it, first-time SSO users have no DaaS role → no policies → the permission enforcer denies every request, and login effectively fails even though the IDP round trip succeeded.
6a. Resolve the default role (setup time, via MCP)
List the app's roles with the DaaS MCP
rolestool:{ "name": "roles", "arguments": { "action": "read" } }Pick the role meant for regular signed-in users (e.g. "Member", "Authenticated", "User") — never an admin role. Confirm it has at least one policy attached (check with the
accesstool) that grants the collections the app needs; if the app has no such role yet, create the role + policy + permissions with/create-rbacfirst.Set
OAUTH_DEFAULT_ROLE_ID=<role-uuid>in.env.local, and push it to the deployed app withamplify_set_env_vars+amplify_redeploy(together with the otherOAUTH_*vars).
6b. Assign at runtime (in lib/supabase/admin.ts)
/**
* Assign the app's default DaaS role to a user that has no role yet.
* Without a role the DaaS permission enforcer denies every request,
* so JIT-provisioned SSO users cannot use the app.
*/
export async function ensureDefaultRole(userId: string): Promise<void> {
const roleId = process.env.OAUTH_DEFAULT_ROLE_ID;
if (!roleId) {
throw new Error(
'OAUTH_DEFAULT_ROLE_ID is not set — SSO users provisioned without a role cannot log in'
);
}
const supabase = createSupabaseAdmin();
// Idempotent: skip if the user already has any role assignment
// (also covers users provisioned before this step existed).
const { data: existing, error: readError } = await supabase
.from('daas_user_roles')
.select('role_id')
.eq('user_id', userId)
.limit(1)
.maybeSingle();
if (readError) {
throw new Error(`Failed to check existing role assignments: ${readError.message}`);
}
if (existing) return;
const { error: insertError } = await supabase
.from('daas_user_roles')
.insert({ user_id: userId, role_id: roleId });
if (insertError) {
throw new Error(`Failed to assign default role: ${insertError.message}`);
}
}
Call it in the callback route for every external OAuth login (the idempotency check makes repeat calls free), and treat failure as fatal:
const { user, session, isNewUser } = await findOrCreateUser({ /* ... */ });
try {
await ensureDefaultRole(user.id);
} catch (roleError) {
console.error('Default role assignment failed:', roleError);
return NextResponse.redirect(
new URL('/login?error=role_assignment_failed', request.url)
);
}
If the insert fails with a foreign-key violation on
daas_users: the auth-user →daas_userssync hasn't produced a profile row for this user yet. Insert one first ({ id: userId, email, status: 'active' }) with the admin client, then retry the role insert.
6c. Verify
Log in with a brand-new IDP account, then check:
select role_id from daas_user_roles where user_id = '<new-auth-user-uuid>';
The row must exist and the user must be able to reach the app's main pages — an authenticated session alone is not a passing test.
Security Considerations
1. State Secret
Generate a cryptographically strong secret:
openssl rand -base64 32
2. Service Role Key Protection
SUPABASE_SERVICE_ROLE_KEY bypasses RLS. Only use in server-side API routes.
3. Token Validation
Always configure JWKS for cryptographic verification when available:
OAUTH_JWKS_URI=https://idp.example.com/.well-known/jwks
OAUTH_ISSUER=https://idp.example.com
4. Cookie Security
Production cookies:
HttpOnly: Prevents XSS accessSecure: HTTPS onlySameSite=Lax: CSRF protection
Troubleshooting
"no_email_claim"
Causes:
- Email not in id_token (check access_token or userinfo)
- Wrong scope requested (ensure
emailscope) - Provider uses non-standard claim name
Debug: Check terminal logs for "Merged claims" to see all available claims.
"state_mismatch"
Causes:
- Cookie blocked (third-party cookies disabled)
- Multiple browser tabs initiated OAuth
- State expired (>10 minutes)
Login Loop
Causes:
- Session cookies not set in correct format
- Middleware not recognizing session
Solution: Ensure using supabase.auth.setSession() not manual cookie setting.
"role_assignment_failed" / Authenticated but Denied Everything
Causes:
OAUTH_DEFAULT_ROLE_IDnot set (locally or in the Amplify environment)- The env var points at a deleted role, or a role with no policies attached
ensureDefaultRole()never wired into the callback (e.g. CLI install without Step 6)- FK violation because no
daas_usersprofile row exists yet (see Step 6b)
Debug: Query daas_user_roles for the user's UUID. No row → runtime
assignment is broken. Row present but still denied → the role's policies/
permissions are the problem, not this skill; fix with /create-rbac.