feat: mobile native better auth support (#10871)

* feat: mobile native better auth support

* chore: add android assetlinks

* chore: add android assetlinks

* chore: add expo fingerpoint

* chore: add relation

* chore: add android origin hash

* chore: update passkey table

* chore: optimize version

* chore: remove as any

* fix: sql not exits problem

* fix: passkey statement

* fix:  passkey origin null

* chore: remove strict peer dependencies

* fix: test case

* chore: remove local passkey origin
This commit is contained in:
Rdmclin2
2025-12-23 15:19:42 +08:00
committed by GitHub
parent cf02912965
commit 8c42a934b3
25 changed files with 10360 additions and 10 deletions
+1
View File
@@ -1,5 +1,6 @@
const config = require('@lobehub/lint').eslint;
config.root = true;
config.extends.push('plugin:@next/next/recommended');
config.rules['unicorn/no-negated-condition'] = 0;
+23
View File
@@ -22,6 +22,7 @@ table agents {
pinned boolean
opening_message text
opening_questions text[] [default: `[]`]
session_group_id text
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
@@ -32,6 +33,7 @@ table agents {
user_id [name: 'agents_user_id_idx']
title [name: 'agents_title_idx']
description [name: 'agents_description_idx']
session_group_id [name: 'agents_session_group_id_idx']
}
}
@@ -170,6 +172,25 @@ table accounts {
}
}
table passkey {
aaguid text
backedUp boolean
counter integer
createdAt timestamp [default: `now()`]
credentialID text [not null]
deviceType text
id text [pk, not null]
name text
publicKey text [not null]
transports text
userId text [not null]
indexes {
credentialID [name: 'passkey_credential_id_unique', unique]
userId [name: 'passkey_user_id_idx']
}
}
table auth_sessions {
created_at timestamp [not null, default: `now()`]
expires_at timestamp [not null]
@@ -1266,6 +1287,8 @@ table user_memories_preferences {
ref: accounts.user_id > users.id
ref: passkey.userId > users.id
ref: auth_sessions.user_id > users.id
ref: two_factor.user_id > users.id
+27
View File
@@ -192,6 +192,33 @@ const nextConfig: NextConfig = {
],
source: '/apple-touch-icon.png',
},
// Passkey configuration files for iOS and Android
{
headers: [
{
key: 'Content-Type',
value: 'application/json',
},
{
key: 'Cache-Control',
value: 'public, max-age=3600',
},
],
source: '/.well-known/apple-app-site-association',
},
{
headers: [
{
key: 'Content-Type',
value: 'application/json',
},
{
key: 'Cache-Control',
value: 'public, max-age=3600',
},
],
source: '/.well-known/assetlinks.json',
},
];
},
logging: {
+2
View File
@@ -136,6 +136,8 @@
"@aws-sdk/s3-request-presigner": "~3.932.0",
"@azure-rest/ai-inference": "1.0.0-beta.5",
"@azure/core-auth": "^1.10.1",
"@better-auth/expo": "^1.4.6",
"@better-auth/passkey": "^1.4.6",
"@cfworker/json-schema": "^4.1.1",
"@clerk/localizations": "^3.30.1",
"@clerk/nextjs": "^6.36.2",
+4 -1
View File
@@ -8,7 +8,10 @@
"test": "vitest",
"test:coverage": "vitest --coverage --silent='passed-only'"
},
"dependencies": {},
"dependencies": {
"@lobechat/types": "workspace:*",
"p-map": "^7.0.4"
},
"devDependencies": {
"openai": "^4.104.0"
}
+5
View File
@@ -8,6 +8,11 @@
},
"main": "./src/index.ts",
"dependencies": {
"@icons-pack/react-simple-icons": "^13.8.0",
"@lobechat/types": "workspace:*",
"@lobehub/ui": "^2.13.8",
"klavis": "^2.15.0",
"lodash-es": "^4.17.21",
"model-bank": "workspace:*",
"query-string": "^9.3.1",
"url-join": "^5.0.0"
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "passkey" (
"aaguid" text,
"backedUp" boolean,
"counter" integer,
"createdAt" timestamp DEFAULT now(),
"credentialID" text NOT NULL,
"deviceType" text,
"id" text PRIMARY KEY NOT NULL,
"name" text,
"publicKey" text NOT NULL,
"transports" text,
"userId" text NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'passkey_userId_users_id_fk') THEN
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "passkey_credential_id_unique" ON "passkey" USING btree ("credentialID");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "passkey_user_id_idx" ON "passkey" USING btree ("userId");
File diff suppressed because it is too large Load Diff
@@ -455,7 +455,14 @@
"when": 1766297832021,
"tag": "0064_add_agents_session_group_id",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1766408202688,
"tag": "0065_add_passkey",
"breakpoints": true
}
],
"version": "6"
}
}
+15
View File
@@ -17,10 +17,25 @@
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobechat/utils": "workspace:*",
"@lobehub/charts": "^2.1.2",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@neondatabase/serverless": "^1.0.2",
"@trpc/server": "^11.7.1",
"debug": "^4.4.3",
"drizzle-zod": "^0.5.1",
"lodash-es": "^4.17.21",
"model-bank": "workspace:*",
"next-auth": "5.0.0-beta.30",
"p-map": "^7.0.4",
"random-words": "^2.0.1",
"ts-md5": "^2.0.1",
"type-fest": "^5.2.0",
"ws": "^8.18.3"
},
"devDependencies": {
"dotenv": "^17.2.3",
"fake-indexeddb": "^6.2.5"
},
"peerDependencies": {
"@electric-sql/pglite": "^0.2.17",
"dayjs": ">=1.11.19",
@@ -1033,5 +1033,16 @@
"bps": true,
"folderMillis": 1766297832021,
"hash": "431a620396060130c46d6174d4bef3a517a0872aff8d19f3044bd9e7dec78ba5"
},
{
"sql": [
"CREATE TABLE IF NOT EXISTS \"passkey\" (\n\t\"aaguid\" text,\n\t\"backedUp\" boolean,\n\t\"counter\" integer,\n\t\"createdAt\" timestamp DEFAULT now(),\n\t\"credentialID\" text NOT NULL,\n\t\"deviceType\" text,\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"name\" text,\n\t\"publicKey\" text NOT NULL,\n\t\"transports\" text,\n\t\"userId\" text NOT NULL\n);\n",
"\nALTER TABLE \"passkey\" ADD CONSTRAINT \"passkey_userId_users_id_fk\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"users\"(\"id\") ON DELETE cascade ON UPDATE no action;",
"\nCREATE UNIQUE INDEX IF NOT EXISTS \"passkey_credential_id_unique\" ON \"passkey\" USING btree (\"credentialID\");",
"\nCREATE INDEX IF NOT EXISTS \"passkey_user_id_idx\" ON \"passkey\" USING btree (\"userId\");"
],
"bps": true,
"folderMillis": 1766408202688,
"hash": "6ca80d3a8c326e30e2a778515ff80e801726894e5b57da29c00f81e1983e4172"
}
]
@@ -23,7 +23,7 @@ describe('TableViewerRepo', () => {
it('should return all tables with counts', async () => {
const result = await repo.getAllTables();
expect(result.length).toEqual(72);
expect(result.length).toEqual(73);
expect(result[0]).toEqual({ name: 'accounts', count: 0, type: 'BASE TABLE' });
});
+40 -1
View File
@@ -1,5 +1,13 @@
import { relations } from 'drizzle-orm';
import { index, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from './user';
@@ -92,8 +100,32 @@ export const twoFactor = pgTable(
],
);
export const passkey = pgTable(
'passkey',
{
aaguid: text('aaguid'),
backedUp: boolean('backedUp'),
counter: integer('counter'),
createdAt: timestamp('createdAt').defaultNow(),
credentialID: text('credentialID').notNull(),
deviceType: text('deviceType'),
id: text('id').primaryKey(),
name: text('name'),
publicKey: text('publicKey').notNull(),
transports: text('transports'),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
},
(table) => [
uniqueIndex('passkey_credential_id_unique').on(table.credentialID),
index('passkey_user_id_idx').on(table.userId),
],
);
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(account),
passkeys: many(passkey),
sessions: many(session),
twoFactors: many(twoFactor),
}));
@@ -118,3 +150,10 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
references: [users.id],
}),
}));
export const passkeysRelations = relations(passkey, ({ one }) => ({
users: one(users, {
fields: [passkey.userId],
references: [users.id],
}),
}));
@@ -4,6 +4,9 @@
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"devDependencies": {
"electron": "^34.0.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
+3
View File
@@ -80,6 +80,9 @@
"test:coverage": "vitest --coverage --silent='passed-only'"
},
"dependencies": {
"type-fest": "^5.2.0"
},
"peerDependencies": {
"zod": "^3.25.76"
}
}
+18 -1
View File
@@ -12,15 +12,32 @@
"test:update": "vitest -u"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.67.1",
"@aws-sdk/client-bedrock-runtime": "^3.941.0",
"@azure-rest/ai-inference": "1.0.0-beta.5",
"@azure/core-auth": "^1.10.1",
"@fal-ai/client": "^1.7.2",
"@google/genai": "^1.29.0",
"@huggingface/inference": "^4.13.4",
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobechat/utils": "workspace:*",
"async-retry": "^1.3.3",
"dayjs": "^1.11.19",
"debug": "^4.4.3",
"immer": "^10.2.0",
"langfuse": "^3.38.6",
"langfuse-core": "^3.38.6",
"lodash-es": "^4.17.21",
"model-bank": "workspace:*",
"nanoid": "^5.1.6",
"ollama": "^0.6.2",
"openai": "^4.104.0",
"replicate": "^1.4.0"
"replicate": "^1.4.0",
"type-fest": "^5.2.0",
"url-join": "^5.0.0"
},
"peerDependencies": {
"zod": "^3.25.76"
}
}
+11
View File
@@ -4,9 +4,20 @@
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@lobechat/model-runtime": "workspace:*",
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/market-sdk": "beta",
"@lobehub/market-types": "^1.11.4",
"@lobehub/ui": "^2.13.8",
"model-bank": "workspace:*",
"react": "19.2.0",
"type-fest": "^4.41.0",
"zod": "^3.25.76",
"zustand": "5.0.4"
},
"peerDependencies": {
"zod": "^3.25.76"
}
}
+24 -2
View File
@@ -16,10 +16,32 @@
"dependencies": {
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@vercel/functions": "^3.3.0",
"brotli-wasm": "^3.0.1",
"chroma-js": "^3.1.2",
"countries-and-timezones": "^3.8.0",
"dayjs": "^1.11.19",
"dompurify": "^3.3.0"
"debug": "^4.4.3",
"dompurify": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"lodash-es": "^4.17.21",
"mime": "^4.1.0",
"model-bank": "workspace:*",
"nanoid": "^5.1.6",
"next": "^16.0.1",
"numeral": "^2.0.6",
"pure-rand": "^7.0.1",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-html": "^16.0.1",
"ssrf-safe-fetch": "workspace:*",
"tokenx": "^1.2.1",
"ua-parser-js": "^1.0.41",
"uuid": "^11.1.0",
"yaml": "^2.8.1"
},
"devDependencies": {
"vitest-canvas-mock": "^0.3.3"
"vitest-canvas-mock": "^1.1.3"
}
}
+1 -1
View File
@@ -12,8 +12,8 @@
"@mozilla/readability": "^0.6.0",
"happy-dom": "^20.0.11",
"node-html-markdown": "^1.3.0",
"ssrf-safe-fetch": "workspace:*",
"query-string": "^9.3.1",
"ssrf-safe-fetch": "workspace:*",
"url-join": "^5"
}
}
@@ -0,0 +1,5 @@
{
"webcredentials": {
"apps": ["4684H589ZU.com.lobehub.app"]
}
}
+18
View File
@@ -0,0 +1,18 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.lobehub.app",
"sha256_cert_fingerprints": [
"D7:54:DB:A3:78:D5:8B:8F:20:01:ED:7B:9B:18:D3:B0:5B:D1:22:AA:97:2B:59:E1:A6:8E:31:24:21:44:0D:2B",
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C",
"1B:21:38:5D:72:40:65:F5:16:20:1D:C9:D2:6B:04:63:C3:33:F1:97:AB:6A:06:66:0E:3E:F0:7E:60:82:7E:E7",
"1B:BE:D4:A0:AE:43:56:E5:58:01:74:C4:B9:A0:0B:0E:5A:B9:5E:0F:A9:C0:65:18:68:CF:1F:AA:3E:8F:4F:DB"
]
}
}
]
+69 -1
View File
@@ -1,15 +1,19 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import { expo } from '@better-auth/expo';
import { passkey } from '@better-auth/passkey';
import { createNanoId, idGenerator, serverDB } from '@lobechat/database';
import * as schema from '@lobechat/database/schemas';
import { emailHarmony } from 'better-auth-harmony';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { betterAuth } from 'better-auth/minimal';
import { admin, genericOAuth, magicLink } from 'better-auth/plugins';
import { admin, emailOTP, genericOAuth, magicLink } from 'better-auth/plugins';
import { authEnv } from '@/envs/auth';
import {
getMagicLinkEmailTemplate,
getResetPasswordEmailTemplate,
getVerificationEmailTemplate,
getVerificationOTPEmailTemplate,
} from '@/libs/better-auth/email-templates';
import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
@@ -20,7 +24,34 @@ import { UserService } from '@/server/services/user';
// Email verification link expiration time (in seconds)
// Default is 1 hour (3600 seconds) as per Better Auth documentation
const VERIFICATION_LINK_EXPIRES_IN = 3600;
/**
* Safely extract hostname from AUTH_URL for passkey rpID.
* Returns undefined if AUTH_URL is not set (e.g., in e2e tests).
*/
const getPasskeyRpID = (): string | undefined => {
if (!authEnv.NEXT_PUBLIC_AUTH_URL) return undefined;
try {
return new URL(authEnv.NEXT_PUBLIC_AUTH_URL).hostname;
} catch {
return undefined;
}
};
/**
* Get passkey origins array.
* Returns undefined if AUTH_URL is not set (e.g., in e2e tests).
*/
const getPasskeyOrigins = (): string[] | undefined => {
if (!authEnv.NEXT_PUBLIC_AUTH_URL) return undefined;
return [
// Web origin
authEnv.NEXT_PUBLIC_AUTH_URL,
];
};
const MAGIC_LINK_EXPIRES_IN = 900;
// OTP expiration time (in seconds) - 5 minutes for mobile OTP verification
const OTP_EXPIRES_IN = 300;
const enableMagicLink = authEnv.NEXT_PUBLIC_ENABLE_MAGIC_LINK;
const enabledSSOProviders = parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
@@ -85,6 +116,8 @@ export const auth = betterAuth({
},
database: drizzleAdapter(serverDB, {
provider: 'pg',
// experimental joins feature needs schema to pass full relation
schema,
}),
secondaryStorage: createSecondaryStorage(),
/**
@@ -149,8 +182,43 @@ export const auth = betterAuth({
},
},
plugins: [
expo(),
emailHarmony({ allowNormalizedSignin: false }),
admin(),
// Email OTP plugin for mobile verification
emailOTP({
expiresIn: OTP_EXPIRES_IN,
otpLength: 6,
allowedAttempts: 3,
// Don't automatically send OTP on sign up - let mobile client manually trigger it
sendVerificationOnSignUp: false,
async sendVerificationOTP({ email, otp }) {
const emailService = new EmailService();
// For all OTP types, use the same template
// userName is optional and will be null since we don't have user context here
const template = getVerificationOTPEmailTemplate({
expiresInSeconds: OTP_EXPIRES_IN,
otp,
userName: null,
});
await emailService.sendMail({
to: email,
...template,
});
},
}),
passkey({
rpName: 'LobeHub',
// Extract rpID from auth URL (e.g., 'lobehub.com' from 'https://lobehub.com')
// Returns undefined if AUTH_URL is not set (e.g., in e2e tests)
rpID: getPasskeyRpID(),
// Support multiple origins: web + Android APK key hashes
// Android origin format: android:apk-key-hash:<base64url-sha256-fingerprint>
// Returns undefined if AUTH_URL is not set (e.g., in e2e tests)
origin: getPasskeyOrigins(),
}),
...(genericOAuthProviders.length > 0
? [
genericOAuth({
@@ -1,3 +1,4 @@
export { getMagicLinkEmailTemplate } from './magic-link';
export { getResetPasswordEmailTemplate } from './reset-password';
export { getVerificationEmailTemplate } from './verification';
export { getVerificationOTPEmailTemplate } from './verification-otp';
@@ -0,0 +1,106 @@
/**
* Email OTP verification template for mobile
* Sent to users when they need to verify their email using OTP code
*/
export const getVerificationOTPEmailTemplate = (params: {
expiresInSeconds: number;
otp: string;
userName?: string | null;
}) => {
const { otp, userName, expiresInSeconds } = params;
// Format expiration time in a human-readable way
const expiresInMinutes = expiresInSeconds / 60;
const expirationText =
expiresInMinutes >= 1
? `${expiresInMinutes} minute${expiresInMinutes > 1 ? 's' : ''}`
: `${expiresInSeconds} seconds`;
return {
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f5; color: #1a1a1a;">
<!-- Container -->
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<!-- Logo -->
<div style="text-align: center; margin-bottom: 32px;">
<div style="display: inline-flex; align-items: center; justify-content: center; background-color: #ffffff; border-radius: 12px; padding: 8px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
<span style="font-size: 24px; line-height: 1; margin-right: 10px;">🤯</span>
<span style="font-size: 18px; font-weight: 700; color: #000000; letter-spacing: -0.5px;">LobeHub</span>
</div>
</div>
<!-- Card -->
<div style="background: #ffffff; border-radius: 20px; padding: 40px; box-shadow: 0 8px 30px rgba(0,0,0,0.04); border: 1px solid rgba(0,0,0,0.02);">
<!-- Header -->
<div style="text-align: center; margin-bottom: 32px;">
<h1 style="color: #111827; font-size: 24px; font-weight: 700; margin: 0 0 12px 0; letter-spacing: -0.5px;">
Verify your email address
</h1>
<p style="color: #6b7280; font-size: 16px; margin: 0;">
Enter this code in the app to complete verification.
</p>
</div>
<!-- Content -->
<div style="color: #374151; font-size: 16px; line-height: 1.6;">
${userName ? `<p style="margin: 0 0 16px 0;">Hi <strong>${userName}</strong>,</p>` : ''}
<p style="margin: 0 0 24px 0;">
Thanks for creating an account with LobeHub. To verify your email address, please use the verification code below:
</p>
<!-- OTP Code Box -->
<div style="text-align: center; margin: 36px 0;">
<div style="display: inline-block; background-color: #000000; padding: 24px 48px; border-radius: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
<div style="font-size: 36px; font-weight: 700; letter-spacing: 12px; color: #ffffff; font-family: 'Courier New', Courier, monospace;">
${otp}
</div>
</div>
</div>
<!-- Expiration Note -->
<div style="background-color: #f9fafb; border-radius: 12px; padding: 16px; margin-bottom: 24px; border: 1px solid #f3f4f6;">
<p style="color: #6b7280; font-size: 14px; margin: 0; text-align: center;">
⏰ This code will expire in <strong>${expirationText}</strong>.
</p>
</div>
<p style="color: #6b7280; font-size: 15px; margin: 0 0 8px 0;">
If you didn't request this code, you can safely ignore this email.
</p>
</div>
<!-- Divider -->
<div style="border-top: 1px solid #e5e7eb; margin: 32px 0;"></div>
<!-- Security Note -->
<div style="text-align: center;">
<p style="color: #9ca3af; font-size: 13px; margin: 0 0 8px 0;">
🔒 For security reasons, never share this code with anyone.
</p>
</div>
</div>
<!-- Footer -->
<div style="text-align: center; margin-top: 32px;">
<p style="color: #a1a1aa; font-size: 13px; margin: 0;">
© 2025 LobeHub. All rights reserved.
</p>
</div>
</div>
</body>
</html>
`,
subject: 'Verify Your Email - LobeHub',
text: `Your verification code is: ${otp}\n\nThis code will expire in ${expirationText}.\n\nIf you didn't request this code, you can safely ignore this email.`,
};
};
+20 -1
View File
@@ -1,15 +1,24 @@
import { authEnv } from '@/envs/auth';
import { getRedisConfig } from '@/envs/redis';
import { initializeRedis, isRedisEnabled } from '@/libs/redis';
import { isDev } from '@/utils/env';
const APPLE_TRUSTED_ORIGIN = 'https://appleid.apple.com';
const MOBILE_APP_SCHEME = 'com.lobehub.app://';
const EXPO_DEV_SCHEME = 'exp://*/*';
/**
* Normalize a URL-like string to an origin with https fallback.
* Returns the original string if it's a custom scheme (e.g., com.lobehub.app://).
*/
export const normalizeOrigin = (url?: string) => {
if (!url) return undefined;
// Handle custom schemes (e.g., mobile app deep links)
if (url.includes('://') && !url.startsWith('http')) {
return url;
}
try {
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
@@ -25,7 +34,14 @@ export const normalizeOrigin = (url?: string) => {
export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
if (authEnv.AUTH_TRUSTED_ORIGINS) {
const originsFromEnv = authEnv.AUTH_TRUSTED_ORIGINS.split(',')
.map((item) => normalizeOrigin(item.trim()))
.map((item) => {
const trimmed = item.trim();
// Handle custom schemes directly
if (trimmed.includes('://') && !trimmed.startsWith('http')) {
return trimmed;
}
return normalizeOrigin(trimmed);
})
.filter(Boolean) as string[];
if (originsFromEnv.length > 0) return Array.from(new Set(originsFromEnv));
@@ -36,6 +52,9 @@ export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
normalizeOrigin(process.env.APP_URL),
normalizeOrigin(process.env.VERCEL_BRANCH_URL),
normalizeOrigin(process.env.VERCEL_URL),
MOBILE_APP_SCHEME,
// Add expo URL in development
...(isDev ? [EXPO_DEV_SCHEME] : []),
].filter(Boolean) as string[];
const baseTrustedOrigins = defaults.length > 0 ? Array.from(new Set(defaults)) : undefined;