원클릭으로
oauth-flow
OAuth 2.0 and OIDC integration with PKCE, Supabase Auth providers, and redirect URI validation
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
OAuth 2.0 and OIDC integration with PKCE, Supabase Auth providers, and redirect URI validation
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Apply this skill for Unite-Hub Supabase migrations, PostgREST/Data API visibility, founder-scoped Playwright journeys, or errors such as PGRST205, access=denied, stale Supabase linked refs, or migration history drift. Prevents repeating the SQL/cache/auth loop by enforcing the exact verification sequence for core journeys.
The compass for Unite-Hub's road to /shipit. Defines the single NorthStar (a real, comprehensive, working founder CRM in production, every section GREEN), the binding definition of GREEN, and the No-Invaders Manifest that keeps the build honest and surgical. Consult BEFORE deciding what to build/skip/finish — it resolves "200 ≠ real" temptations and scope-creep pressure. P1, auto-loaded.
Apply this skill WHEN scaffolding a new cron "pull" route that syncs external/derived data into Supabase on a schedule (Vercel cron). Encodes the Unite-Hub cron invariants: CRON_SECRET auth, FOUNDER_USER_ID actor, overlap safety, idempotent upsert, last-sync timestamp, and failure surfacing. Generic `cron-scheduler` covers scheduling; this covers the PULL handler body. P3.
Apply this skill WHEN verifying that a route, page, or integration serves REAL data and not silent mock/placeholder data. Detects the "false-green" failure mode: an endpoint returns 200 (or a page renders) while the underlying data is fabricated because a provider is unconnected. Trigger WHENEVER classifying a section's readiness, reviewing integration wrappers, or before marking anything GREEN. P2 — load on audit/verify tasks.
Manifest-first context isolation — each subagent receives only its scope, never the full codebase
Apply this skill for ANY decision with non-obvious tradeoffs: architectural choices, debugging without a clear root cause, performance strategies, security decisions, feature design with competing constraints, refactoring scope decisions. Forces multi-perspective analysis before committing to a solution. P1 auto-load — always active on complex reasoning tasks.
| name | oauth-flow |
| type | skill |
| version | 1.0.0 |
| priority | 2 |
| domain | security |
| description | OAuth 2.0 and OIDC integration with PKCE, Supabase Auth providers, and redirect URI validation |
OAuth 2.0 and OIDC integration patterns with PKCE, provider configuration, and session management for NodeJS-Starter-V1.
| Field | Value |
|---|---|
| Skill ID | oauth-flow |
| Category | Authentication & Security |
| Complexity | High |
| Complements | api-client, rbac-patterns, secret-management |
| Version | 1.0.0 |
| Locale | en-AU |
Codifies OAuth 2.0 and OpenID Connect patterns for NodeJS-Starter-V1: authorisation code flow with PKCE, provider configuration for Google and GitHub, Supabase Auth integration, session management, token refresh, account linking, and security best practices for redirect URI validation.
auth/jwt.py)rbac-patterns skill)secret-management skill)csrf-protection skill)// apps/web/lib/supabase/auth-config.ts
export const oauthProviders = [
{
provider: "google" as const,
label: "Google",
scopes: "openid email profile",
queryParams: {
access_type: "offline", // Request refresh token
prompt: "consent", // Force consent screen
},
},
{
provider: "github" as const,
label: "GitHub",
scopes: "read:user user:email",
},
] as const;
export type OAuthProvider = (typeof oauthProviders)[number]["provider"];
Project Reference: apps/web/components/auth/oauth-providers.tsx — the existing component renders Google and GitHub buttons. apps/web/app/auth/callback/route.ts — the callback handler exchanges the authorisation code for a Supabase session.
import { createClient } from "@/lib/supabase/client";
async function signInWithProvider(
provider: OAuthProvider,
redirectTo?: string,
): Promise<void> {
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback${
redirectTo ? `?next=${encodeURIComponent(redirectTo)}` : ""
}`,
queryParams: provider === "google"
? { access_type: "offline", prompt: "consent" }
: undefined,
},
});
if (error) {
throw new Error(`OAuth sign-in failed: ${error.message}`);
}
}
// apps/web/app/auth/callback/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = searchParams.get("next") ?? "/dashboard";
const error = searchParams.get("error");
if (error) {
const description = searchParams.get("error_description") ?? error;
return NextResponse.redirect(
`${origin}/login?error=${encodeURIComponent(description)}`,
);
}
if (!code) {
return NextResponse.redirect(
`${origin}/login?error=${encodeURIComponent("No authorisation code")}`,
);
}
const supabase = await createClient();
const { error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) {
return NextResponse.redirect(
`${origin}/login?error=${encodeURIComponent(exchangeError.message)}`,
);
}
return NextResponse.redirect(`${origin}${next}`);
}
Rule: The code parameter is single-use. If the exchange fails, redirect to login with the error — never retry code exchange.
import { createClient } from "@/lib/supabase/client";
export async function getSession() {
const supabase = createClient();
const { data: { session }, error } = await supabase.auth.getSession();
if (error || !session) {
return null;
}
// Check if token needs refresh (within 60 seconds of expiry)
const expiresAt = session.expires_at ?? 0;
const now = Math.floor(Date.now() / 1000);
if (expiresAt - now < 60) {
const { data: { session: refreshed } } =
await supabase.auth.refreshSession();
return refreshed;
}
return session;
}
// Auth state listener
export function onAuthStateChange(
callback: (event: string, session: unknown) => void,
) {
const supabase = createClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
callback(event, session);
},
);
return subscription;
}
Rule: Never store tokens in localStorage. Supabase client handles storage via httpOnly cookies when configured with the server-side client.
async function linkProvider(provider: OAuthProvider): Promise<void> {
const supabase = createClient();
const { error } = await supabase.auth.linkIdentity({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback?next=/settings`,
},
});
if (error) {
throw new Error(`Account linking failed: ${error.message}`);
}
}
async function unlinkProvider(identityId: string): Promise<void> {
const supabase = createClient();
const { error } = await supabase.auth.unlinkIdentity({
id: identityId,
// Prevent unlinking the last identity
});
if (error) {
throw new Error(`Unlink failed: ${error.message}`);
}
}
async function getLinkedProviders(): Promise<string[]> {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user?.identities) return [];
return user.identities.map((i) => i.provider);
}
from fastapi import Depends, HTTPException, Request
from jose import jwt, JWTError
SUPABASE_JWT_SECRET = settings.SUPABASE_JWT_SECRET
async def get_current_user_from_oauth(request: Request):
"""Validate Supabase JWT from the Authorization header."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
token = auth_header.removeprefix("Bearer ")
try:
payload = jwt.decode(
token,
SUPABASE_JWT_SECRET,
algorithms=["HS256"],
audience="authenticated",
)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token claims")
# Fetch or create user in local database
user = await get_or_create_user(user_id, payload)
return user
Complements: rbac-patterns skill — after extracting the user from the JWT, apply permission checks via require_permission().
const ALLOWED_REDIRECT_HOSTS = new Set([
"localhost",
"127.0.0.1",
process.env.NEXT_PUBLIC_APP_URL
? new URL(process.env.NEXT_PUBLIC_APP_URL).hostname
: "",
].filter(Boolean));
function isValidRedirectUri(uri: string): boolean {
try {
const url = new URL(uri);
return ALLOWED_REDIRECT_HOSTS.has(url.hostname);
} catch {
// Relative paths are allowed
return uri.startsWith("/") && !uri.startsWith("//");
}
}
Rule: Always validate the next or redirectTo parameter against the whitelist. Open redirect attacks use OAuth callbacks to phish users.
| Pattern | Problem | Correct Approach |
|---|---|---|
| Implicit flow (no PKCE) | Token exposed in URL fragment | Authorisation code + PKCE |
| Tokens in localStorage | XSS can steal tokens | httpOnly cookies via Supabase |
| No redirect URI validation | Open redirect vulnerability | Whitelist allowed hosts |
| Retry failed code exchange | Code is single-use, replay attack risk | Redirect to login on failure |
| Hardcoded client secrets in frontend | Secret exposed in bundle | Server-side only, env variables |
| No account linking | Users create duplicate accounts | Support multiple providers per user |
Before merging oauth-flow changes:
jose or equivalentWhen applying this skill, structure implementation as:
### OAuth Flow Implementation
**Flow**: [authorisation code + PKCE / device code]
**Providers**: [Google, GitHub / custom]
**Auth Library**: [Supabase Auth / NextAuth / custom]
**Token Storage**: [httpOnly cookies / server session]
**Account Linking**: [enabled / disabled]
**Redirect Validation**: [whitelist / regex / none]