mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"type-fest": "^5.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.`,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user