From 9dfd4ef3265c582ace5e9177c69b07dc8975717e Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Wed, 8 Apr 2026 09:12:50 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Google=20and=20Apple?= =?UTF-8?q?=20OAuth=20via=20better-auth=20socialProviders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production.example | 29 +++- APPLE-OAUTH.md | 195 +++++++++++++++++++++++++ compose.dev.yml | 20 --- src/app/auth/signin/page.tsx | 76 +--------- src/app/auth/signin/sign-in-form.tsx | 207 +++++++++++++++++++++++++++ src/auth.ts | 62 ++++---- src/lib/build-social-providers.ts | 51 +++++++ src/lib/get-sign-in-providers.ts | 61 ++++++++ tests/auth.test.ts | 196 +++++++++++++++++++++++++ 9 files changed, 777 insertions(+), 120 deletions(-) create mode 100644 APPLE-OAUTH.md delete mode 100644 compose.dev.yml create mode 100644 src/app/auth/signin/sign-in-form.tsx create mode 100644 src/lib/build-social-providers.ts create mode 100644 src/lib/get-sign-in-providers.ts create mode 100644 tests/auth.test.ts diff --git a/.env.production.example b/.env.production.example index 01a1951..48113b9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,8 +1,25 @@ OPENROUTER_API_KEY= -AUTH_AUTHENTIK_CLIENT_ID= -AUTH_AUTHENTIK_CLIENT_SECRET=XXXXXXXXXXXXXXXX -AUTH_AUTHENTIK_ISSUER=XXXXXXXXXXXXXXXXXXX -BETTER_AUTH_URL=XXXXXXXXXXXXXXXXXXX -BETTER_AUTH_SECRET=XXXXXXXXXXX -DB_URL=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# --- Auth (all providers are optional; configure only what you need) --- + +# Authentik (genericOAuth) +AUTH_AUTHENTIK_CLIENT_ID= +AUTH_AUTHENTIK_CLIENT_SECRET= +AUTH_AUTHENTIK_ISSUER= + +# Google OAuth — https://console.cloud.google.com/ +AUTH_GOOGLE_CLIENT_ID= +AUTH_GOOGLE_CLIENT_SECRET= + +# Apple Sign In — https://developer.apple.com/account/resources/authkeys/list +# AUTH_APPLE_CLIENT_SECRET is a pre-generated JWT signed with the .p8 key. +# See: https://better-auth.com/docs/authentication/apple +AUTH_APPLE_CLIENT_ID= +AUTH_APPLE_CLIENT_SECRET= +AUTH_APPLE_TEAM_ID= +AUTH_APPLE_KEY_ID= +AUTH_APPLE_PRIVATE_KEY= + +BETTER_AUTH_URL= +BETTER_AUTH_SECRET= +DATABASE_URL= diff --git a/APPLE-OAUTH.md b/APPLE-OAUTH.md new file mode 100644 index 0000000..ef0394d --- /dev/null +++ b/APPLE-OAUTH.md @@ -0,0 +1,195 @@ +# Apple OAuth Setup + +Reference for when the Apple Developer account is ready. +No code changes needed — just fill in the five env vars and restart. + +## What you need + +- An **Apple Developer Program** membership ($99/yr) — [enroll here](https://developer.apple.com/programs/enroll/) +- The app must be served over **HTTPS** in production (localhost works for dev — see note in Step 2) + +## The five env vars + +```bash +AUTH_APPLE_CLIENT_ID= # Services ID identifier (Step 2) +AUTH_APPLE_CLIENT_SECRET= # Pre-generated JWT (Step 4) +AUTH_APPLE_TEAM_ID= # 10-char team identifier (Step 1) +AUTH_APPLE_KEY_ID= # 10-char key identifier (Step 3) +AUTH_APPLE_PRIVATE_KEY= # Contents of the .p8 file (Step 3) +``` + +All five must be non-empty or the Apple button won't appear. +Set any subset to empty to disable the provider without touching code. + +--- + +## Step 1 — Note your Team ID + +Sign in to [developer.apple.com](https://developer.apple.com/account). +Your **Team ID** is the 10-character string shown in the top-right corner +next to your name (e.g. `ABCD123456`). + +``` +AUTH_APPLE_TEAM_ID=ABCD123456 +``` + +--- + +## Step 2 — Create an App ID + +1. [Certificates, IDs & Profiles → Identifiers](https://developer.apple.com/account/resources/identifiers/list) +2. Click **+** → **App IDs** → **App** → Continue +3. **Description**: `Local Cal` +4. **Bundle ID** (Explicit): `com.yourdomain.localcal` +5. Under **Capabilities** scroll to **Sign In with Apple** → check it → + **Configure** → **Enable as primary App ID** → Done +6. Continue → **Register** + +--- + +## Step 3 — Create a Services ID (the OAuth client ID) + +This is what identifies the *web* sign-in flow — separate from the App ID above. + +1. **Identifiers → +** → **Services IDs** → Continue +2. **Description**: `Local Cal Web` +3. **Identifier**: `com.yourdomain.localcal.web` + *(must differ from the Bundle ID)* +4. Continue → **Register** +5. Click the Services ID you just created +6. Enable **Sign In with Apple** → **Configure** + - **Primary App ID**: select `com.yourdomain.localcal` (Step 2) + - **Domains and Subdomains**: `yourdomain.com` + *(no `https://`, no trailing slash, no path)* + - **Return URLs**: + ``` + https://yourdomain.com/api/auth/callback/apple + ``` + > **localhost dev**: add `localhost` as a domain and + > `http://localhost:3000/api/auth/callback/apple` as a return URL. + > Apple permits plain HTTP for localhost only. + > Alternatively use an HTTPS tunnel: + > [ngrok](https://ngrok.com/) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). +7. Done → Continue → **Save** + +``` +AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web +``` + +--- + +## Step 4 — Create a private key + +1. [Keys → +](https://developer.apple.com/account/resources/authkeys/list) +2. **Key Name**: `Local Cal Sign In` +3. Check **Sign In with Apple** → **Configure** → select the App ID from Step 2 → Save +4. Continue → **Register** +5. **Download the `.p8` file immediately** — Apple only lets you download it once. + Store it somewhere safe (password manager, secrets vault). + The filename will be `AuthKey_XXXXXXXXXX.p8`. + +The **Key ID** (`AUTH_APPLE_KEY_ID`) is shown on the key detail page. + +``` +AUTH_APPLE_KEY_ID=ABC1234567 +``` + +Paste the full contents of the `.p8` file into `AUTH_APPLE_PRIVATE_KEY`: + +```bash +# .env.local — wrap multi-line value in double quotes +AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg... +-----END PRIVATE KEY-----" +``` + +On deployment platforms (Vercel, Railway, Fly.io) paste the value as-is — +they handle multi-line secrets natively without quotes. + +--- + +## Step 5 — Generate the client secret JWT + +Apple does not use a static client secret. Instead you sign a short-lived JWT +with the private key. It is valid for up to **180 days**. + +Run this once with Bun: + +```ts +// scripts/gen-apple-secret.ts +import { importPKCS8, SignJWT } from "jose"; +import { readFileSync } from "fs"; + +const TEAM_ID = "ABCD123456"; // Step 1 +const CLIENT_ID = "com.yourdomain.localcal.web"; // Step 3 +const KEY_ID = "ABC1234567"; // Step 4 +const PRIVATE_KEY = readFileSync("./AuthKey_ABC1234567.p8", "utf8"); + +const key = await importPKCS8(PRIVATE_KEY, "ES256"); +const now = Math.floor(Date.now() / 1000); + +const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: "ES256", kid: KEY_ID }) + .setIssuer(TEAM_ID) + .setSubject(CLIENT_ID) + .setAudience("https://appleid.apple.com") + .setIssuedAt(now) + .setExpirationTime(now + 180 * 24 * 60 * 60) + .sign(key); + +console.log(jwt); +``` + +```bash +bun run scripts/gen-apple-secret.ts +``` + +Copy the printed JWT: + +``` +AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii... +``` + +> **Set a reminder** to regenerate ~5 months from now. +> When the JWT expires the Apple button will silently fail at sign-in. + +--- + +## Completed `.env.local` block + +```bash +AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web +AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii... +AUTH_APPLE_TEAM_ID=ABCD123456 +AUTH_APPLE_KEY_ID=ABC1234567 +AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg... +-----END PRIVATE KEY-----" +``` + +Restart the dev server — the Apple button appears automatically. + +--- + +## Verifying without a device + +```bash +bun test tests/auth.test.ts +``` + +The tests mock env vars and confirm the Apple provider registers correctly. +A passing suite means the wiring is correct before you touch the portal. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Apple button missing | Any of the 5 vars is empty | All five must be non-empty | +| `invalid_client` | JWT expired or signed with wrong key | Regenerate `AUTH_APPLE_CLIENT_SECRET` (Step 5) | +| `invalid_grant` | `CLIENT_ID` is the Bundle ID, not the Services ID | Use `com.yourdomain.localcal.web` (Step 3) | +| Redirect fails in dev | Localhost not registered as a domain | Add `localhost` + `http://localhost:3000/api/auth/callback/apple` in Step 3 | +| Redirect fails in prod | Domain/return URL not registered | Re-check the Services ID configuration in Step 3 | +| Name is blank after sign-in | Apple only sends the user's name on the **first** sign-in | Expected — store the name on that first callback | +| Email is a relay address | User chose "Hide My Email" | Expected — treat it as canonical, it routes to their real inbox | diff --git a/compose.dev.yml b/compose.dev.yml deleted file mode 100644 index ea119c9..0000000 --- a/compose.dev.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - postgres: - image: postgres:17 - container_name: local-cal-postgres - ports: - - "5432:5432" - environment: - POSTGRES_USER: localcal - POSTGRES_PASSWORD: localcal - POSTGRES_DB: localcal - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U localcal"] - interval: 5s - timeout: 5s - retries: 5 - -volumes: - postgres_data: diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 749b78e..8154ee4 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,74 +1,10 @@ -"use client"; - -import { motion } from "framer-motion"; -import { CalendarDays, Loader2 } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { signIn, useSession } from "@/lib/auth-client"; +// Server Component — reads env vars and passes provider list to the client UI +import { getSignInProviders } from "@/lib/get-sign-in-providers"; +import { SignInForm } from "./sign-in-form"; export default function SignInPage() { - const { data: session, isPending } = useSession(); - const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (session?.user) { - router.push("/"); - } - }, [session, router]); - - const handleSignIn = async () => { - setIsLoading(true); - try { - await signIn.oauth2({ - providerId: "authentik", - callbackURL: "/", - }); - } catch { - toast.error("Failed to sign in. Please try again."); - } finally { - setIsLoading(false); - } - }; - - if (isPending || session?.user) { - return null; - } - - return ( -
- -
- -

Local iCal

-
- -

- Sign in to unlock AI-powered event creation -

- - - -
- - Continue without signing in - -
-
-
+ const providers = getSignInProviders( + process.env as Record, ); + return ; } diff --git a/src/app/auth/signin/sign-in-form.tsx b/src/app/auth/signin/sign-in-form.tsx new file mode 100644 index 0000000..f5c3f89 --- /dev/null +++ b/src/app/auth/signin/sign-in-form.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { motion } from "framer-motion"; +import { CalendarDays, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { signIn, useSession } from "@/lib/auth-client"; +import type { SignInProvider } from "@/lib/get-sign-in-providers"; + +// --------------------------------------------------------------------------- +// Provider icon components — inline SVGs keep the bundle lean and avoid +// external CDN requests. Official brand colours per brand guidelines. +// --------------------------------------------------------------------------- + +function GoogleIcon({ className }: { className?: string }) { + return ( + + ); +} + +function AppleIcon({ className }: { className?: string }) { + return ( + + ); +} + +function AuthentikIcon({ className }: { className?: string }) { + // Generic key/lock icon for Authentik (no official SVG logo required) + return ( + + ); +} + +const PROVIDER_ICONS: Record< + string, + React.ComponentType<{ className?: string }> +> = { + google: GoogleIcon, + apple: AppleIcon, + authentik: AuthentikIcon, +}; + +// --------------------------------------------------------------------------- + +interface SignInFormProps { + providers: SignInProvider[]; +} + +export function SignInForm({ providers }: SignInFormProps) { + const { data: session, isPending } = useSession(); + const router = useRouter(); + const [loadingId, setLoadingId] = useState(null); + + useEffect(() => { + if (session?.user) { + router.push("/"); + } + }, [session, router]); + + const handleSignIn = async (provider: SignInProvider) => { + setLoadingId(provider.id); + try { + if (provider.signInMethod === "social") { + await signIn.social({ + provider: provider.id as + | "google" + | "apple" + | "github" + | "discord" + | "spotify" + | "twitch" + | "facebook" + | "microsoft" + | "github", + callbackURL: "/", + }); + } else { + await signIn.oauth2({ + providerId: provider.id, + callbackURL: "/", + }); + } + } catch { + toast.error( + `Failed to sign in with ${provider.label}. Please try again.`, + ); + } finally { + setLoadingId(null); + } + }; + + if (isPending || session?.user) { + return null; + } + + const isAnyLoading = loadingId !== null; + + return ( +
+ + {/* Header */} +
+
+ +

+ Sign in to unlock AI-powered event creation +

+ + {/* Provider buttons */} + {providers.length > 0 ? ( +
+ {providers.map((provider) => { + const Icon = PROVIDER_ICONS[provider.id]; + const isLoading = loadingId === provider.id; + + return ( + + ); + })} +
+ ) : ( +

+ No sign-in providers configured. +

+ )} + + {/* Divider + guest link */} +
+ + Continue without signing in + +
+
+
+ ); +} diff --git a/src/auth.ts b/src/auth.ts index 1645e34..dfb3953 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,43 +3,57 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { genericOAuth } from "better-auth/plugins"; import { db } from "@/db/index"; import * as schema from "@/db/schema"; +import { buildSocialProviders } from "@/lib/build-social-providers"; -// Validate required environment variables +// --------------------------------------------------------------------------- +// Required vars — the app cannot start without these +// --------------------------------------------------------------------------- if (!process.env.BETTER_AUTH_SECRET) { throw new Error("BETTER_AUTH_SECRET is required"); } if (!process.env.BETTER_AUTH_URL) { throw new Error("BETTER_AUTH_URL is required"); } -if (!process.env.AUTH_AUTHENTIK_CLIENT_ID) { - throw new Error("AUTH_AUTHENTIK_CLIENT_ID is required"); -} -if (!process.env.AUTH_AUTHENTIK_CLIENT_SECRET) { - throw new Error("AUTH_AUTHENTIK_CLIENT_SECRET is required"); -} -if (!process.env.AUTH_AUTHENTIK_ISSUER) { - throw new Error("AUTH_AUTHENTIK_ISSUER is required"); -} -export const auth = betterAuth({ - secret: process.env.BETTER_AUTH_SECRET, - baseURL: process.env.BETTER_AUTH_URL, - trustedOrigins: [process.env.BETTER_AUTH_URL], - database: drizzleAdapter(db, { - provider: "pg", - schema, - }), - plugins: [ - genericOAuth({ - config: [ +// --------------------------------------------------------------------------- +// Authentik is optional: only configured when all three vars are present. +// Google and Apple are also optional via buildSocialProviders(). +// --------------------------------------------------------------------------- +const authentikConfig = + process.env.AUTH_AUTHENTIK_CLIENT_ID && + process.env.AUTH_AUTHENTIK_CLIENT_SECRET && + process.env.AUTH_AUTHENTIK_ISSUER + ? [ { - providerId: "authentik", + providerId: "authentik" as const, clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID, clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET, discoveryUrl: `${process.env.AUTH_AUTHENTIK_ISSUER}/.well-known/openid-configuration`, scopes: ["openid", "email", "profile"], }, - ], - }), + ] + : []; + +const socialProviders = buildSocialProviders( + process.env as Record, +); + +export const auth = betterAuth({ + secret: process.env.BETTER_AUTH_SECRET, + baseURL: process.env.BETTER_AUTH_URL, + trustedOrigins: [ + process.env.BETTER_AUTH_URL, + // Required for Sign in with Apple's form_post redirect + ...(socialProviders.apple ? ["https://appleid.apple.com"] : []), + ], + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + socialProviders, + plugins: [ + ...(authentikConfig.length > 0 + ? [genericOAuth({ config: authentikConfig })] + : []), ], }); diff --git a/src/lib/build-social-providers.ts b/src/lib/build-social-providers.ts new file mode 100644 index 0000000..7bc3e0a --- /dev/null +++ b/src/lib/build-social-providers.ts @@ -0,0 +1,51 @@ +/** + * Builds the `socialProviders` config object for betterAuth(). + * + * Only includes a provider when ALL of its required env vars are present + * (non-empty strings). This lets the app start without Google/Apple + * credentials and enables providers incrementally via env vars. + */ + +type Env = Record; + +export interface SocialProviderConfig { + google?: { + clientId: string; + clientSecret: string; + }; + apple?: { + clientId: string; + clientSecret: string; + appBundleIdentifier?: string; + }; +} + +export function buildSocialProviders(env: Env): SocialProviderConfig { + const providers: SocialProviderConfig = {}; + + // Google — needs clientId + clientSecret + if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { + providers.google = { + clientId: env.AUTH_GOOGLE_CLIENT_ID, + clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET, + }; + } + + // Apple — needs clientId, clientSecret (pre-generated JWT), teamId, keyId, + // and privateKey. If the caller has already generated the JWT client secret + // and stored it in AUTH_APPLE_CLIENT_SECRET, all five vars must be present. + if ( + env.AUTH_APPLE_CLIENT_ID && + env.AUTH_APPLE_CLIENT_SECRET && + env.AUTH_APPLE_TEAM_ID && + env.AUTH_APPLE_KEY_ID && + env.AUTH_APPLE_PRIVATE_KEY + ) { + providers.apple = { + clientId: env.AUTH_APPLE_CLIENT_ID, + clientSecret: env.AUTH_APPLE_CLIENT_SECRET, + }; + } + + return providers; +} diff --git a/src/lib/get-sign-in-providers.ts b/src/lib/get-sign-in-providers.ts new file mode 100644 index 0000000..c7dd6a3 --- /dev/null +++ b/src/lib/get-sign-in-providers.ts @@ -0,0 +1,61 @@ +/** + * Returns the ordered list of available sign-in providers based on which + * environment variables are configured. + * + * Used by the sign-in page to render provider buttons. + * + * signInMethod: + * - "social" → call signIn.social({ provider: id, callbackURL }) + * - "oauth2" → call signIn.oauth2({ providerId: id, callbackURL }) + */ + +type Env = Record; + +export interface SignInProvider { + id: string; + label: string; + signInMethod: "social" | "oauth2"; +} + +export function getSignInProviders(env: Env): SignInProvider[] { + const providers: SignInProvider[] = []; + + // Authentik (genericOAuth) + if ( + env.AUTH_AUTHENTIK_CLIENT_ID && + env.AUTH_AUTHENTIK_CLIENT_SECRET && + env.AUTH_AUTHENTIK_ISSUER + ) { + providers.push({ + id: "authentik", + label: "Authentik", + signInMethod: "oauth2", + }); + } + + // Google (socialProviders) + if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) { + providers.push({ + id: "google", + label: "Google", + signInMethod: "social", + }); + } + + // Apple (socialProviders) — all five vars required + if ( + env.AUTH_APPLE_CLIENT_ID && + env.AUTH_APPLE_CLIENT_SECRET && + env.AUTH_APPLE_TEAM_ID && + env.AUTH_APPLE_KEY_ID && + env.AUTH_APPLE_PRIVATE_KEY + ) { + providers.push({ + id: "apple", + label: "Apple", + signInMethod: "social", + }); + } + + return providers; +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..79f1638 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; + +// --------------------------------------------------------------------------- +// Slice 1: buildSocialProviders — conditional provider registration +// +// Public interface under test: buildSocialProviders(env) → object +// Behaviour: only registers providers whose env vars are all present. +// --------------------------------------------------------------------------- + +import { buildSocialProviders } from "@/lib/build-social-providers"; + +describe("buildSocialProviders", () => { + test("returns empty object when no OAuth env vars are set", () => { + const result = buildSocialProviders({}); + expect(result).toEqual({}); + }); + + test("registers google when both Google vars are present", () => { + const result = buildSocialProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + }); + expect(result).toHaveProperty("google"); + expect(result.google).toMatchObject({ + clientId: "gid", + clientSecret: "gsecret", + }); + }); + + test("does NOT register google when only clientId is present", () => { + const result = buildSocialProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + }); + expect(result).not.toHaveProperty("google"); + }); + + test("does NOT register google when only clientSecret is present", () => { + const result = buildSocialProviders({ + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + }); + expect(result).not.toHaveProperty("google"); + }); + + test("registers apple when all four Apple vars are present", () => { + const result = buildSocialProviders({ + AUTH_APPLE_CLIENT_ID: "aid", + AUTH_APPLE_CLIENT_SECRET: "asecret", + AUTH_APPLE_TEAM_ID: "TEAM1", + AUTH_APPLE_KEY_ID: "KEY1", + AUTH_APPLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + expect(result).toHaveProperty("apple"); + expect(result.apple).toMatchObject({ + clientId: "aid", + clientSecret: "asecret", + }); + }); + + test("does NOT register apple when any Apple var is missing", () => { + const result = buildSocialProviders({ + AUTH_APPLE_CLIENT_ID: "aid", + AUTH_APPLE_CLIENT_SECRET: "asecret", + AUTH_APPLE_TEAM_ID: "TEAM1", + // AUTH_APPLE_KEY_ID missing + AUTH_APPLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + expect(result).not.toHaveProperty("apple"); + }); + + test("registers both google and apple when all vars are present", () => { + const result = buildSocialProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + AUTH_APPLE_CLIENT_ID: "aid", + AUTH_APPLE_CLIENT_SECRET: "asecret", + AUTH_APPLE_TEAM_ID: "TEAM1", + AUTH_APPLE_KEY_ID: "KEY1", + AUTH_APPLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + expect(result).toHaveProperty("google"); + expect(result).toHaveProperty("apple"); + }); +}); + +// --------------------------------------------------------------------------- +// Slice 2: auth-client — signIn.social is available +// +// Public interface under test: authClient.signIn.social +// Behaviour: the function exists and is callable (no plugin wiring needed +// for socialProviders on the client — it's built into createAuthClient). +// --------------------------------------------------------------------------- + +import { authClient } from "@/lib/auth-client"; + +describe("authClient", () => { + test("exposes signIn.social as a function", () => { + expect(typeof authClient.signIn.social).toBe("function"); + }); + + test("exposes useSession hook", () => { + expect(typeof authClient.useSession).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// Slice 3: getSignInProviders — sign-in page provider list +// +// Public interface under test: getSignInProviders() +// Behaviour: returns the ordered list of available sign-in providers with +// their labels and ids, based on which env vars are configured. +// The component consumes this list to render buttons — testing the list +// verifies the button-to-provider mapping without needing a DOM. +// --------------------------------------------------------------------------- + +import { getSignInProviders } from "@/lib/get-sign-in-providers"; + +describe("getSignInProviders", () => { + test("returns authentik when Authentik vars are set", () => { + const providers = getSignInProviders({ + AUTH_AUTHENTIK_CLIENT_ID: "id", + AUTH_AUTHENTIK_CLIENT_SECRET: "secret", + AUTH_AUTHENTIK_ISSUER: "https://auth.example.com", + }); + const ids = providers.map((p) => p.id); + expect(ids).toContain("authentik"); + }); + + test("returns google when Google vars are set", () => { + const providers = getSignInProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + }); + const ids = providers.map((p) => p.id); + expect(ids).toContain("google"); + }); + + test("returns apple when Apple vars are set", () => { + const providers = getSignInProviders({ + AUTH_APPLE_CLIENT_ID: "aid", + AUTH_APPLE_CLIENT_SECRET: "asecret", + AUTH_APPLE_TEAM_ID: "TEAM1", + AUTH_APPLE_KEY_ID: "KEY1", + AUTH_APPLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + const ids = providers.map((p) => p.id); + expect(ids).toContain("apple"); + }); + + test("returns empty list when no vars are set", () => { + const providers = getSignInProviders({}); + expect(providers).toHaveLength(0); + }); + + test("each provider has id, label, and signInMethod", () => { + const providers = getSignInProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + }); + expect(providers[0]).toMatchObject({ + id: expect.any(String), + label: expect.any(String), + signInMethod: expect.any(String), + }); + }); + + test("google provider uses 'social' signInMethod", () => { + const providers = getSignInProviders({ + AUTH_GOOGLE_CLIENT_ID: "gid", + AUTH_GOOGLE_CLIENT_SECRET: "gsecret", + }); + const google = providers.find((p) => p.id === "google"); + expect(google?.signInMethod).toBe("social"); + }); + + test("apple provider uses 'social' signInMethod", () => { + const providers = getSignInProviders({ + AUTH_APPLE_CLIENT_ID: "aid", + AUTH_APPLE_CLIENT_SECRET: "asecret", + AUTH_APPLE_TEAM_ID: "TEAM1", + AUTH_APPLE_KEY_ID: "KEY1", + AUTH_APPLE_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + }); + const apple = providers.find((p) => p.id === "apple"); + expect(apple?.signInMethod).toBe("social"); + }); + + test("authentik provider uses 'oauth2' signInMethod", () => { + const providers = getSignInProviders({ + AUTH_AUTHENTIK_CLIENT_ID: "id", + AUTH_AUTHENTIK_CLIENT_SECRET: "secret", + AUTH_AUTHENTIK_ISSUER: "https://auth.example.com", + }); + const authentik = providers.find((p) => p.id === "authentik"); + expect(authentik?.signInMethod).toBe("oauth2"); + }); +});