feat: add Google and Apple OAuth via better-auth socialProviders

This commit is contained in:
2026-04-08 09:12:50 -04:00
parent e59476dea9
commit 9dfd4ef326
9 changed files with 777 additions and 120 deletions

View File

@@ -1,8 +1,25 @@
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
AUTH_AUTHENTIK_CLIENT_ID=
AUTH_AUTHENTIK_CLIENT_SECRET=XXXXXXXXXXXXXXXX
AUTH_AUTHENTIK_ISSUER=XXXXXXXXXXXXXXXXXXX
BETTER_AUTH_URL=XXXXXXXXXXXXXXXXXXX # --- Auth (all providers are optional; configure only what you need) ---
BETTER_AUTH_SECRET=XXXXXXXXXXX
DB_URL=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # 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=

195
APPLE-OAUTH.md Normal file
View File

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

View File

@@ -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:

View File

@@ -1,74 +1,10 @@
"use client"; // Server Component — reads env vars and passes provider list to the client UI
import { getSignInProviders } from "@/lib/get-sign-in-providers";
import { motion } from "framer-motion"; import { SignInForm } from "./sign-in-form";
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";
export default function SignInPage() { export default function SignInPage() {
const { data: session, isPending } = useSession(); const providers = getSignInProviders(
const router = useRouter(); process.env as Record<string, string | undefined>,
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 (
<div className="min-h-screen flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="glass-strong p-8 max-w-sm w-full text-center"
>
<div className="flex items-center justify-center gap-2 mb-6">
<CalendarDays className="h-6 w-6 text-primary" />
<h1 className="text-xl font-semibold tracking-tight">Local iCal</h1>
</div>
<p className="text-sm text-muted-foreground mb-6">
Sign in to unlock AI-powered event creation
</p>
<Button onClick={handleSignIn} className="w-full" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isLoading ? "Signing in..." : "Continue with Authentik"}
</Button>
<div className="mt-4">
<Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Continue without signing in
</Link>
</div>
</motion.div>
</div>
); );
return <SignInForm providers={providers} />;
} }

View File

@@ -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 (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="none"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
function AppleIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="currentColor"
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
);
}
function AuthentikIcon({ className }: { className?: string }) {
// Generic key/lock icon for Authentik (no official SVG logo required)
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
);
}
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<string | null>(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 (
<div className="min-h-dvh flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="glass-strong p-8 max-w-sm w-full"
>
{/* Header */}
<div className="flex items-center justify-center gap-2 mb-2">
<CalendarDays className="h-6 w-6 text-primary" aria-hidden="true" />
<h1 className="text-xl font-semibold tracking-tight">Local iCal</h1>
</div>
<p className="text-sm text-muted-foreground text-center mb-7">
Sign in to unlock AI-powered event creation
</p>
{/* Provider buttons */}
{providers.length > 0 ? (
<div className="flex flex-col gap-3">
{providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.id];
const isLoading = loadingId === provider.id;
return (
<Button
key={provider.id}
variant="outline"
className="w-full h-11 gap-3 justify-center font-medium cursor-pointer transition-colors"
onClick={() => handleSignIn(provider)}
disabled={isAnyLoading}
aria-label={`Sign in with ${provider.label}`}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
) : Icon ? (
<Icon className="h-5 w-5 shrink-0" />
) : null}
<span>
{isLoading
? "Signing in…"
: `Continue with ${provider.label}`}
</span>
</Button>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
No sign-in providers configured.
</p>
)}
{/* Divider + guest link */}
<div className="mt-6 pt-5 border-t border-border/50 text-center">
<Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Continue without signing in
</Link>
</div>
</motion.div>
</div>
);
}

View File

@@ -3,43 +3,57 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { db } from "@/db/index"; import { db } from "@/db/index";
import * as schema from "@/db/schema"; 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) { if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required"); throw new Error("BETTER_AUTH_SECRET is required");
} }
if (!process.env.BETTER_AUTH_URL) { if (!process.env.BETTER_AUTH_URL) {
throw new Error("BETTER_AUTH_URL is required"); 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, // Authentik is optional: only configured when all three vars are present.
baseURL: process.env.BETTER_AUTH_URL, // Google and Apple are also optional via buildSocialProviders().
trustedOrigins: [process.env.BETTER_AUTH_URL], // ---------------------------------------------------------------------------
database: drizzleAdapter(db, { const authentikConfig =
provider: "pg", process.env.AUTH_AUTHENTIK_CLIENT_ID &&
schema, process.env.AUTH_AUTHENTIK_CLIENT_SECRET &&
}), process.env.AUTH_AUTHENTIK_ISSUER
plugins: [ ? [
genericOAuth({
config: [
{ {
providerId: "authentik", providerId: "authentik" as const,
clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID, clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET, clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET,
discoveryUrl: `${process.env.AUTH_AUTHENTIK_ISSUER}/.well-known/openid-configuration`, discoveryUrl: `${process.env.AUTH_AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"], scopes: ["openid", "email", "profile"],
}, },
]
: [];
const socialProviders = buildSocialProviders(
process.env as Record<string, string | undefined>,
);
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 })]
: []),
], ],
}); });

View File

@@ -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<string, string | undefined>;
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;
}

View File

@@ -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<string, string | undefined>;
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;
}

196
tests/auth.test.ts Normal file
View File

@@ -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");
});
});