# Add External OAUTH

> Add external OAuth/OIDC identity provider integration where Next.js acts as the OAuth client. Creates OAuth proxy flow with PKCE, user provisioning via Supabase Admin API with default DaaS role assignment, and session management. Required for self-hosted Supabase without deployment access.

- Skill: `buildpad-ai/add-external-oauth` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add buildpad-ai/add-external-oauth`
- Raw SKILL.md: https://api.skillmd.com/api/skills/buildpad-ai/add-external-oauth/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: buildpad-ai (https://skillmd.com/u/buildpad-ai)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/buildpad-ai/add-external-oauth

---


# 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

```mermaid
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 token
- `codeVerifier`: PKCE verifier (sensitive)
- `provider`: OAuth provider name
- `returnTo`: Post-login redirect URL
- `createdAt`: 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 |
| Google | 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:
1. Decode access_token as JWT (if not opaque)
2. Validate id_token (if present)
3. Call userinfo endpoint (if configured)
4. 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()`:

```typescript
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:

1. **Setup time (you, the agent)**: resolve the default role's UUID with the DaaS MCP `roles` tool. If the app has no suitable non-admin role with policies attached, create one via `/create-rbac` first. Store the UUID in the `OAUTH_DEFAULT_ROLE_ID` env var.
2. **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:
```typescript
// 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:
```typescript
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:

```sql
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.

1. Call the `get_project_detail` MCP tool.
2. Look at the `connectors` array in the response for a `status: "connected"`
   entry whose `envVarNames` includes `OAUTH_CLIENT_ID` — that's the signal a
   connector is IDP-shaped (as opposed to a plain API-key connector like
   Stripe), regardless of its `provider` name. Don't match on provider name or
   on how the user phrased their request — check the data.
3. If found, use its `envVars` directly — they're already the exact
   `OAUTH_*` 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.local` for local
   dev, and call the `amplify_set_env_vars` + `amplify_redeploy` MCP 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/callback` URL 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 MCP `roles` tool yourself and push
   > it alongside the connector env vars — SSO logins fail without it.
4. If no such connector is `connected` yet, 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:

```bash
# 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 encryption
- `lib/oauth/validate.ts` — JWKS token validation + claim normalization
- `lib/oauth/index.ts` — barrel export
- `lib/supabase/admin.ts` — JIT user provisioning via Admin API
- `app/api/auth/oauth/[provider]/route.ts` — OAuth initiation route
- `app/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`,
> add `ensureDefaultRole()`, 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

```bash
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.

```env
# ═══════════════════════════════════════════════════════════════════════
# 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`.

```typescript
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 base64url
- `generateCodeChallenge(verifier)` — SHA-256 hash of verifier, base64url
- `generateState()` — 16 random bytes as hex (CSRF token)
- `encryptState(data)` — AES-256-GCM encrypt object to base64url string
- `decryptState<T>(encrypted)` — decrypt and JSON parse
- `createOAuthState(provider, returnTo)` — creates the full `OAuthStateData` object
- `isStateExpired(stateData)` — checks `createdAt` against 10-minute expiry

```typescript
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-only
- `decodeTokenUnsafe(token)` — decode JWT without verification (for access_token claim extraction)
- `normalizeUserClaims(claims)` — extracts `email`, `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 using `SUPABASE_SERVICE_ROLE_KEY`
- `findOrCreateUser(options)` — paginates `listUsers()` to find by email (no `getUserByEmail` exists!), then creates/updates, generates session via magic link + OTP verification
- `generateUserSession(supabase, user)` — uses `admin.generateLink('magiclink')` + `verifyOtp()` to get real `access_token` / `refresh_token`
- `ensureDefaultRole(userId)` — assigns `OAUTH_DEFAULT_ROLE_ID` in `daas_user_roles` if the user has no role yet (Step 6 — required, or SSO users are denied everything)

```typescript
export async function findOrCreateUser(options: FindOrCreateUserOptions): Promise<UserSessionResult>
// Returns: { user, session: { access_token, refresh_token, ... }, isNewUser }
```

#### `app/api/auth/oauth/[provider]/route.ts`

```typescript
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.

```typescript
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`

```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

```tsx
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)

1. List the app's roles with the DaaS MCP `roles` tool:

   ```json
   { "name": "roles", "arguments": { "action": "read" } }
   ```

2. 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 `access` tool) that grants the
   collections the app needs; if the app has no such role yet, create the
   role + policy + permissions with `/create-rbac` first.
3. Set `OAUTH_DEFAULT_ROLE_ID=<role-uuid>` in `.env.local`, and push it to
   the deployed app with `amplify_set_env_vars` + `amplify_redeploy`
   (together with the other `OAUTH_*` vars).

#### 6b. Assign at runtime (in `lib/supabase/admin.ts`)

```typescript
/**
 * 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:

```typescript
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_users` sync 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:

```sql
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:
```bash
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:
```env
OAUTH_JWKS_URI=https://idp.example.com/.well-known/jwks
OAUTH_ISSUER=https://idp.example.com
```

### 4. Cookie Security

Production cookies:
- `HttpOnly`: Prevents XSS access
- `Secure`: HTTPS only
- `SameSite=Lax`: CSRF protection

## Troubleshooting

### "no_email_claim"

**Causes**:
1. Email not in id_token (check access_token or userinfo)
2. Wrong scope requested (ensure `email` scope)
3. Provider uses non-standard claim name

**Debug**: Check terminal logs for "Merged claims" to see all available claims.

### "state_mismatch"

**Causes**:
1. Cookie blocked (third-party cookies disabled)
2. Multiple browser tabs initiated OAuth
3. State expired (>10 minutes)

### Login Loop

**Causes**:
1. Session cookies not set in correct format
2. Middleware not recognizing session

**Solution**: Ensure using `supabase.auth.setSession()` not manual cookie setting.

### "role_assignment_failed" / Authenticated but Denied Everything

**Causes**:
1. `OAUTH_DEFAULT_ROLE_ID` not set (locally or in the Amplify environment)
2. The env var points at a deleted role, or a role with no policies attached
3. `ensureDefaultRole()` never wired into the callback (e.g. CLI install
   without Step 6)
4. FK violation because no `daas_users` profile 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`.

## References

- [OAuth 2.0 RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
- [Supabase Admin API](https://supabase.com/docs/reference/javascript/admin-api)
- [Supabase SSR](https://supabase.com/docs/guides/auth/server-side/nextjs)

