✨ feat: add Google and Apple OAuth via better-auth socialProviders
This commit is contained in:
@@ -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
195
APPLE-OAUTH.md
Normal 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 |
|
||||||
@@ -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:
|
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
207
src/app/auth/signin/sign-in-form.tsx
Normal file
207
src/app/auth/signin/sign-in-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/auth.ts
58
src/auth.ts
@@ -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 })]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
51
src/lib/build-social-providers.ts
Normal file
51
src/lib/build-social-providers.ts
Normal 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;
|
||||||
|
}
|
||||||
61
src/lib/get-sign-in-providers.ts
Normal file
61
src/lib/get-sign-in-providers.ts
Normal 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
196
tests/auth.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user