@lobehub/lobehub 2.0.0-next.124 → 2.0.0-next.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.cursor/rules/db-migrations.mdc +16 -1
  2. package/.cursor/rules/project-introduce.mdc +1 -1
  3. package/.cursor/rules/project-structure.mdc +20 -2
  4. package/.env.example +148 -65
  5. package/.env.example.development +6 -8
  6. package/AGENTS.md +1 -3
  7. package/CHANGELOG.md +50 -0
  8. package/Dockerfile +7 -5
  9. package/GEMINI.md +63 -0
  10. package/changelog/v1.json +18 -0
  11. package/docs/development/database-schema.dbml +37 -0
  12. package/docs/self-hosting/advanced/auth.mdx +82 -2
  13. package/docs/self-hosting/advanced/auth.zh-CN.mdx +82 -2
  14. package/docs/self-hosting/environment-variables/auth.mdx +187 -1
  15. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
  16. package/locales/en-US/auth.json +93 -0
  17. package/locales/zh-CN/auth.json +107 -1
  18. package/package.json +5 -2
  19. package/packages/const/src/auth.ts +2 -1
  20. package/packages/database/migrations/0049_better_auth.sql +49 -0
  21. package/packages/database/migrations/meta/0048_snapshot.json +312 -932
  22. package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
  23. package/packages/database/migrations/meta/_journal.json +8 -1
  24. package/packages/database/src/core/migrations.json +13 -0
  25. package/packages/database/src/index.ts +1 -0
  26. package/packages/database/src/models/__tests__/session.test.ts +1 -2
  27. package/packages/database/src/models/user.ts +9 -8
  28. package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
  29. package/packages/database/src/schemas/betterAuth.ts +63 -0
  30. package/packages/database/src/schemas/index.ts +1 -0
  31. package/packages/database/src/schemas/ragEvals.ts +1 -2
  32. package/packages/database/src/schemas/user.ts +3 -2
  33. package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
  34. package/packages/types/src/user/preference.ts +11 -0
  35. package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
  36. package/packages/utils/src/server/auth.ts +18 -1
  37. package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
  38. package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
  39. package/src/app/(backend)/middleware/auth/index.ts +14 -0
  40. package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
  41. package/src/app/(backend)/middleware/auth/utils.ts +13 -10
  42. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
  43. package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
  44. package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
  45. package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
  46. package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
  47. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
  48. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
  49. package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
  50. package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
  51. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
  52. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
  53. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
  54. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
  55. package/src/auth.ts +118 -0
  56. package/src/components/NextAuth/AuthIcons.tsx +3 -1
  57. package/src/envs/auth.ts +260 -13
  58. package/src/envs/email.ts +37 -0
  59. package/src/features/User/UserPanel/PanelContent.tsx +6 -5
  60. package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
  61. package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
  62. package/src/features/User/__tests__/useMenu.test.tsx +14 -12
  63. package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
  64. package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
  65. package/src/layout/AuthProvider/index.tsx +3 -0
  66. package/src/libs/better-auth/auth-client.ts +34 -0
  67. package/src/libs/better-auth/constants.ts +13 -0
  68. package/src/libs/better-auth/email-templates/index.ts +3 -0
  69. package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
  70. package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
  71. package/src/libs/better-auth/email-templates/verification.ts +108 -0
  72. package/src/libs/better-auth/sso/helpers.ts +61 -0
  73. package/src/libs/better-auth/sso/index.ts +113 -0
  74. package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
  75. package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
  76. package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
  77. package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
  78. package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
  79. package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
  80. package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
  81. package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
  82. package/src/libs/better-auth/sso/providers/github.ts +30 -0
  83. package/src/libs/better-auth/sso/providers/google.ts +30 -0
  84. package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
  85. package/src/libs/better-auth/sso/providers/logto.ts +38 -0
  86. package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
  87. package/src/libs/better-auth/sso/providers/okta.ts +37 -0
  88. package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
  89. package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
  90. package/src/libs/better-auth/sso/types.ts +25 -0
  91. package/src/libs/better-auth/utils/client.ts +1 -0
  92. package/src/libs/better-auth/utils/common.ts +20 -0
  93. package/src/libs/better-auth/utils/server.test.ts +61 -0
  94. package/src/libs/better-auth/utils/server.ts +18 -0
  95. package/src/libs/trpc/lambda/context.test.ts +116 -0
  96. package/src/libs/trpc/lambda/context.ts +27 -0
  97. package/src/libs/trpc/middleware/userAuth.ts +4 -2
  98. package/src/locales/default/auth.ts +114 -1
  99. package/src/proxy.ts +71 -7
  100. package/src/server/globalConfig/index.ts +12 -1
  101. package/src/server/routers/lambda/user.ts +4 -0
  102. package/src/server/services/email/README.md +241 -0
  103. package/src/server/services/email/impls/index.test.ts +39 -0
  104. package/src/server/services/email/impls/index.ts +32 -0
  105. package/src/server/services/email/impls/nodemailer/index.ts +108 -0
  106. package/src/server/services/email/impls/nodemailer/type.ts +31 -0
  107. package/src/server/services/email/impls/type.ts +61 -0
  108. package/src/server/services/email/index.test.ts +144 -0
  109. package/src/server/services/email/index.ts +40 -0
  110. package/src/services/user/index.test.ts +162 -2
  111. package/src/services/user/index.ts +6 -3
  112. package/src/store/user/slices/auth/action.test.ts +213 -16
  113. package/src/store/user/slices/auth/action.ts +86 -1
  114. package/src/store/user/slices/auth/initialState.ts +13 -2
  115. package/src/store/user/slices/auth/selectors.ts +6 -2
  116. package/src/store/user/slices/common/action.ts +5 -1
  117. package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
@@ -0,0 +1,41 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ const provider: GenericProviderDefinition<{
7
+ AUTH_CLOUDFLARE_ZERO_TRUST_ID: string;
8
+ AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER: string;
9
+ AUTH_CLOUDFLARE_ZERO_TRUST_SECRET: string;
10
+ }> = {
11
+ build: (env) =>
12
+ buildOidcConfig({
13
+ clientId: env.AUTH_CLOUDFLARE_ZERO_TRUST_ID,
14
+ clientSecret: env.AUTH_CLOUDFLARE_ZERO_TRUST_SECRET,
15
+ issuer: env.AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER,
16
+ overrides: {
17
+ mapProfileToUser: (profile) => ({
18
+ email: profile.email,
19
+ name: profile.name ?? profile.email ?? profile.sub,
20
+ }),
21
+ },
22
+ providerId: 'cloudflare-zero-trust',
23
+ }),
24
+ checkEnvs: () => {
25
+ return !!(
26
+ authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_ID &&
27
+ authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_SECRET &&
28
+ authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER
29
+ )
30
+ ? {
31
+ AUTH_CLOUDFLARE_ZERO_TRUST_ID: authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_ID,
32
+ AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER: authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER,
33
+ AUTH_CLOUDFLARE_ZERO_TRUST_SECRET: authEnv.AUTH_CLOUDFLARE_ZERO_TRUST_SECRET,
34
+ }
35
+ : false;
36
+ },
37
+ id: 'cloudflare-zero-trust',
38
+ type: 'generic',
39
+ };
40
+
41
+ export default provider;
@@ -0,0 +1,45 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import type { BuiltinProviderDefinition } from '../types';
4
+
5
+ const provider: BuiltinProviderDefinition<
6
+ {
7
+ AUTH_COGNITO_DOMAIN: string;
8
+ AUTH_COGNITO_ID: string;
9
+ AUTH_COGNITO_REGION: string;
10
+ AUTH_COGNITO_SECRET: string;
11
+ AUTH_COGNITO_USERPOOL_ID: string;
12
+ },
13
+ 'cognito'
14
+ > = {
15
+ build: (env) => {
16
+ return {
17
+ clientId: env.AUTH_COGNITO_ID,
18
+ clientSecret: env.AUTH_COGNITO_SECRET,
19
+ domain: env.AUTH_COGNITO_DOMAIN,
20
+ region: env.AUTH_COGNITO_REGION,
21
+ userPoolId: env.AUTH_COGNITO_USERPOOL_ID,
22
+ };
23
+ },
24
+ checkEnvs: () => {
25
+ return !!(
26
+ authEnv.AUTH_COGNITO_ID &&
27
+ authEnv.AUTH_COGNITO_SECRET &&
28
+ authEnv.AUTH_COGNITO_DOMAIN &&
29
+ authEnv.AUTH_COGNITO_REGION &&
30
+ authEnv.AUTH_COGNITO_USERPOOL_ID
31
+ )
32
+ ? {
33
+ AUTH_COGNITO_DOMAIN: authEnv.AUTH_COGNITO_DOMAIN,
34
+ AUTH_COGNITO_ID: authEnv.AUTH_COGNITO_ID,
35
+ AUTH_COGNITO_REGION: authEnv.AUTH_COGNITO_REGION,
36
+ AUTH_COGNITO_SECRET: authEnv.AUTH_COGNITO_SECRET,
37
+ AUTH_COGNITO_USERPOOL_ID: authEnv.AUTH_COGNITO_USERPOOL_ID,
38
+ }
39
+ : false;
40
+ },
41
+ id: 'cognito',
42
+ type: 'builtin',
43
+ };
44
+
45
+ export default provider;
@@ -0,0 +1,181 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import type { GenericProviderDefinition } from '../types';
4
+
5
+ const FEISHU_AUTHORIZATION_URL = 'https://accounts.feishu.cn/open-apis/authen/v1/authorize';
6
+ const FEISHU_TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v2/oauth/token';
7
+ const FEISHU_USERINFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info';
8
+
9
+ type FeishuUserProfile = {
10
+ avatar_big?: string;
11
+ avatar_middle?: string;
12
+ avatar_thumb?: string;
13
+ avatar_url?: string;
14
+ email?: string;
15
+ en_name?: string;
16
+ enterprise_email?: string;
17
+ name?: string;
18
+ open_id?: string;
19
+ tenant_key?: string;
20
+ union_id?: string;
21
+ };
22
+
23
+ type FeishuUserInfoResponse = {
24
+ code?: number;
25
+ data?: FeishuUserProfile;
26
+ msg?: string;
27
+ };
28
+
29
+ type FeishuTokenPayload = {
30
+ access_token?: string;
31
+ expires_in?: number;
32
+ refresh_token?: string;
33
+ scope?: string;
34
+ tokenType?: string;
35
+ token_type?: string;
36
+ };
37
+
38
+ type FeishuTokenResponse = {
39
+ code?: number;
40
+ data?: FeishuTokenPayload;
41
+ message?: string;
42
+ msg?: string;
43
+ } & FeishuTokenPayload;
44
+
45
+ const isFeishuProfile = (value: unknown): value is FeishuUserProfile => {
46
+ if (!value || typeof value !== 'object') return false;
47
+ const candidate = value as Record<string, unknown>;
48
+ return (
49
+ typeof candidate.union_id === 'string' ||
50
+ typeof candidate.open_id === 'string' ||
51
+ typeof candidate.avatar_url === 'string' ||
52
+ typeof candidate.name === 'string'
53
+ );
54
+ };
55
+
56
+ const parseScopes = (scope: string | undefined) =>
57
+ scope ? scope.split(/[\s,]+/).filter(Boolean) : [];
58
+
59
+ const provider: GenericProviderDefinition<{
60
+ AUTH_FEISHU_APP_ID: string;
61
+ AUTH_FEISHU_APP_SECRET: string;
62
+ }> = {
63
+ build: (env) => {
64
+ const clientId = env.AUTH_FEISHU_APP_ID;
65
+ const clientSecret = env.AUTH_FEISHU_APP_SECRET;
66
+
67
+ return {
68
+ authorizationUrl: FEISHU_AUTHORIZATION_URL,
69
+ authorizationUrlParams: {
70
+ app_id: clientId,
71
+ response_type: 'code',
72
+ scope: '',
73
+ },
74
+ clientId,
75
+ clientSecret,
76
+ /**
77
+ * Exchange code directly with Feishu (no proxy needed).
78
+ */
79
+ getToken: async ({ code, redirectURI }) => {
80
+ const tokenResponse = await fetch(FEISHU_TOKEN_URL, {
81
+ body: JSON.stringify({
82
+ app_id: clientId,
83
+ app_secret: clientSecret,
84
+ code,
85
+ grant_type: 'authorization_code',
86
+ redirect_uri: redirectURI,
87
+ }),
88
+ cache: 'no-store',
89
+ headers: {
90
+ 'content-type': 'application/json; charset=utf-8',
91
+ },
92
+ method: 'POST',
93
+ });
94
+
95
+ const parsed = (await tokenResponse.json()) as FeishuTokenResponse;
96
+ const payload = parsed.data ?? parsed;
97
+
98
+ const hasErrorCode = typeof parsed.code === 'number' && parsed.code !== 0;
99
+ const tokenMissing = !payload.access_token;
100
+
101
+ if (!tokenResponse.ok || hasErrorCode || tokenMissing) {
102
+ throw new Error(parsed.msg ?? parsed.message ?? 'Failed to fetch Feishu OAuth token');
103
+ }
104
+
105
+ return {
106
+ accessToken: payload.access_token,
107
+ accessTokenExpiresAt: payload.expires_in
108
+ ? new Date(Date.now() + payload.expires_in * 1000)
109
+ : undefined,
110
+ expiresIn: payload.expires_in,
111
+ raw: parsed,
112
+ refreshToken: payload.refresh_token,
113
+ scopes: parseScopes(payload.scope),
114
+ tokenType: payload.token_type ?? payload.tokenType ?? 'Bearer',
115
+ };
116
+ },
117
+ getUserInfo: async (tokens) => {
118
+ if (!tokens.accessToken) return null;
119
+
120
+ const response = await fetch(FEISHU_USERINFO_URL, {
121
+ cache: 'no-store',
122
+ headers: {
123
+ Authorization: `Bearer ${tokens.accessToken}`,
124
+ },
125
+ });
126
+
127
+ if (!response.ok) {
128
+ return null;
129
+ }
130
+
131
+ const payload = (await response.json()) as unknown;
132
+ const profileResponse = payload as FeishuUserInfoResponse;
133
+
134
+ if (profileResponse.code && profileResponse.code !== 0) {
135
+ return null;
136
+ }
137
+
138
+ const profile: FeishuUserProfile | undefined =
139
+ profileResponse.data ?? (isFeishuProfile(payload) ? payload : undefined);
140
+
141
+ if (!profile) return null;
142
+
143
+ const unionId = profile.union_id ?? profile.open_id;
144
+ if (!unionId) return null;
145
+
146
+ const syntheticEmail =
147
+ profile.email ?? profile.enterprise_email ?? `${unionId}@feishu.lobehub`;
148
+
149
+ return {
150
+ email: syntheticEmail,
151
+ emailVerified: false,
152
+ id: unionId,
153
+ image:
154
+ profile.avatar_url ??
155
+ profile.avatar_thumb ??
156
+ profile.avatar_middle ??
157
+ profile.avatar_big,
158
+ name: profile.name ?? profile.en_name ?? unionId,
159
+ ...profile,
160
+ };
161
+ },
162
+ pkce: false,
163
+ providerId: 'feishu',
164
+ responseMode: 'query',
165
+ scopes: [],
166
+ };
167
+ },
168
+
169
+ checkEnvs: () => {
170
+ return !!(authEnv.AUTH_FEISHU_APP_ID && authEnv.AUTH_FEISHU_APP_SECRET)
171
+ ? {
172
+ AUTH_FEISHU_APP_ID: authEnv.AUTH_FEISHU_APP_ID,
173
+ AUTH_FEISHU_APP_SECRET: authEnv.AUTH_FEISHU_APP_SECRET,
174
+ }
175
+ : false;
176
+ },
177
+ id: 'feishu',
178
+ type: 'generic',
179
+ };
180
+
181
+ export default provider;
@@ -0,0 +1,44 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ const provider: GenericProviderDefinition<{
7
+ AUTH_GENERIC_OIDC_ID: string;
8
+ AUTH_GENERIC_OIDC_ISSUER: string;
9
+ AUTH_GENERIC_OIDC_SECRET: string;
10
+ }> = {
11
+ build: (env) =>
12
+ buildOidcConfig({
13
+ clientId: env.AUTH_GENERIC_OIDC_ID,
14
+ clientSecret: env.AUTH_GENERIC_OIDC_SECRET,
15
+ issuer: env.AUTH_GENERIC_OIDC_ISSUER,
16
+ overrides: {
17
+ /**
18
+ * Mirror NextAuth's fallback that prefers name -> username -> email so Better Auth never
19
+ * fails with name_is_missing when upstream profiles only expose username/email fields.
20
+ */
21
+ mapProfileToUser: (profile) => ({
22
+ name: profile.name ?? profile.username ?? profile.email ?? profile.id,
23
+ }),
24
+ },
25
+ providerId: 'generic-oidc',
26
+ }),
27
+ checkEnvs: () => {
28
+ return !!(
29
+ authEnv.AUTH_GENERIC_OIDC_ID &&
30
+ authEnv.AUTH_GENERIC_OIDC_SECRET &&
31
+ authEnv.AUTH_GENERIC_OIDC_ISSUER
32
+ )
33
+ ? {
34
+ AUTH_GENERIC_OIDC_ID: authEnv.AUTH_GENERIC_OIDC_ID,
35
+ AUTH_GENERIC_OIDC_ISSUER: authEnv.AUTH_GENERIC_OIDC_ISSUER,
36
+ AUTH_GENERIC_OIDC_SECRET: authEnv.AUTH_GENERIC_OIDC_SECRET,
37
+ }
38
+ : false;
39
+ },
40
+ id: 'generic-oidc',
41
+ type: 'generic',
42
+ };
43
+
44
+ export default provider;
@@ -0,0 +1,30 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import type { BuiltinProviderDefinition } from '../types';
4
+
5
+ const provider: BuiltinProviderDefinition<
6
+ {
7
+ AUTH_GITHUB_ID: string;
8
+ AUTH_GITHUB_SECRET: string;
9
+ },
10
+ 'github'
11
+ > = {
12
+ build: (env) => {
13
+ return {
14
+ clientId: env.AUTH_GITHUB_ID,
15
+ clientSecret: env.AUTH_GITHUB_SECRET,
16
+ };
17
+ },
18
+ checkEnvs: () => {
19
+ return !!(authEnv.AUTH_GITHUB_ID && authEnv.AUTH_GITHUB_SECRET)
20
+ ? {
21
+ AUTH_GITHUB_ID: authEnv.AUTH_GITHUB_ID,
22
+ AUTH_GITHUB_SECRET: authEnv.AUTH_GITHUB_SECRET,
23
+ }
24
+ : false;
25
+ },
26
+ id: 'github',
27
+ type: 'builtin',
28
+ };
29
+
30
+ export default provider;
@@ -0,0 +1,30 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import type { BuiltinProviderDefinition } from '../types';
4
+
5
+ const provider: BuiltinProviderDefinition<
6
+ {
7
+ AUTH_GOOGLE_ID: string;
8
+ AUTH_GOOGLE_SECRET: string;
9
+ },
10
+ 'google'
11
+ > = {
12
+ build: (env) => {
13
+ return {
14
+ clientId: env.AUTH_GOOGLE_ID,
15
+ clientSecret: env.AUTH_GOOGLE_SECRET,
16
+ };
17
+ },
18
+ checkEnvs: () => {
19
+ return !!(authEnv.AUTH_GOOGLE_ID && authEnv.AUTH_GOOGLE_SECRET)
20
+ ? {
21
+ AUTH_GOOGLE_ID: authEnv.AUTH_GOOGLE_ID,
22
+ AUTH_GOOGLE_SECRET: authEnv.AUTH_GOOGLE_SECRET,
23
+ }
24
+ : false;
25
+ },
26
+ id: 'google',
27
+ type: 'builtin',
28
+ };
29
+
30
+ export default provider;
@@ -0,0 +1,35 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ const provider: GenericProviderDefinition<{
7
+ AUTH_KEYCLOAK_ID: string;
8
+ AUTH_KEYCLOAK_ISSUER: string;
9
+ AUTH_KEYCLOAK_SECRET: string;
10
+ }> = {
11
+ build: (env) =>
12
+ buildOidcConfig({
13
+ clientId: env.AUTH_KEYCLOAK_ID,
14
+ clientSecret: env.AUTH_KEYCLOAK_SECRET,
15
+ issuer: env.AUTH_KEYCLOAK_ISSUER,
16
+ providerId: 'keycloak',
17
+ }),
18
+ checkEnvs: () => {
19
+ return !!(
20
+ authEnv.AUTH_KEYCLOAK_ID &&
21
+ authEnv.AUTH_KEYCLOAK_SECRET &&
22
+ authEnv.AUTH_KEYCLOAK_ISSUER
23
+ )
24
+ ? {
25
+ AUTH_KEYCLOAK_ID: authEnv.AUTH_KEYCLOAK_ID,
26
+ AUTH_KEYCLOAK_ISSUER: authEnv.AUTH_KEYCLOAK_ISSUER,
27
+ AUTH_KEYCLOAK_SECRET: authEnv.AUTH_KEYCLOAK_SECRET,
28
+ }
29
+ : false;
30
+ },
31
+ id: 'keycloak',
32
+ type: 'generic',
33
+ };
34
+
35
+ export default provider;
@@ -0,0 +1,38 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ const provider: GenericProviderDefinition<{
7
+ AUTH_LOGTO_ID: string;
8
+ AUTH_LOGTO_ISSUER: string;
9
+ AUTH_LOGTO_SECRET: string;
10
+ }> = {
11
+ build: (env) =>
12
+ buildOidcConfig({
13
+ clientId: env.AUTH_LOGTO_ID,
14
+ clientSecret: env.AUTH_LOGTO_SECRET,
15
+ issuer: env.AUTH_LOGTO_ISSUER,
16
+ overrides: {
17
+ mapProfileToUser: (profile) => ({
18
+ email: profile.email,
19
+ name: profile.name ?? profile.username ?? profile.email ?? profile.sub,
20
+ }),
21
+ },
22
+ providerId: 'logto',
23
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
24
+ }),
25
+ checkEnvs: () => {
26
+ return !!(authEnv.AUTH_LOGTO_ID && authEnv.AUTH_LOGTO_SECRET && authEnv.AUTH_LOGTO_ISSUER)
27
+ ? {
28
+ AUTH_LOGTO_ID: authEnv.AUTH_LOGTO_ID,
29
+ AUTH_LOGTO_ISSUER: authEnv.AUTH_LOGTO_ISSUER,
30
+ AUTH_LOGTO_SECRET: authEnv.AUTH_LOGTO_SECRET,
31
+ }
32
+ : false;
33
+ },
34
+ id: 'logto',
35
+ type: 'generic',
36
+ };
37
+
38
+ export default provider;
@@ -0,0 +1,65 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { pickEnv } from '../helpers';
4
+ import type { BuiltinProviderDefinition } from '../types';
5
+
6
+ type MicrosoftEnv = {
7
+ AUTH_AZURE_AD_ID?: string;
8
+ AUTH_AZURE_AD_SECRET?: string;
9
+ AUTH_MICROSOFT_ENTRA_ID_ID?: string;
10
+ AUTH_MICROSOFT_ENTRA_ID_SECRET?: string;
11
+ AUTH_MICROSOFT_ID?: string;
12
+ AUTH_MICROSOFT_SECRET?: string;
13
+ AZURE_AD_CLIENT_ID?: string;
14
+ AZURE_AD_CLIENT_SECRET?: string;
15
+ };
16
+
17
+ const getClientId = (env: MicrosoftEnv) => {
18
+ return pickEnv(
19
+ env.AUTH_MICROSOFT_ID,
20
+ env.AUTH_MICROSOFT_ENTRA_ID_ID,
21
+ env.AUTH_AZURE_AD_ID,
22
+ env.AZURE_AD_CLIENT_ID,
23
+ );
24
+ };
25
+
26
+ const getClientSecret = (env: MicrosoftEnv) => {
27
+ return pickEnv(
28
+ env.AUTH_MICROSOFT_SECRET,
29
+ env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
30
+ env.AUTH_AZURE_AD_SECRET,
31
+ env.AZURE_AD_CLIENT_SECRET,
32
+ );
33
+ };
34
+
35
+ const provider: BuiltinProviderDefinition<MicrosoftEnv, 'microsoft'> = {
36
+ aliases: ['microsoft-entra-id'],
37
+ build: (env) => {
38
+ const clientId = getClientId(env)!;
39
+ const clientSecret = getClientSecret(env)!;
40
+ return {
41
+ clientId,
42
+ clientSecret,
43
+ };
44
+ },
45
+ checkEnvs: () => {
46
+ const clientId = getClientId(authEnv);
47
+ const clientSecret = getClientSecret(authEnv);
48
+ return !!(clientId && clientSecret)
49
+ ? {
50
+ AUTH_AZURE_AD_ID: authEnv.AUTH_AZURE_AD_ID,
51
+ AUTH_AZURE_AD_SECRET: authEnv.AUTH_AZURE_AD_SECRET,
52
+ AUTH_MICROSOFT_ENTRA_ID_ID: authEnv.AUTH_MICROSOFT_ENTRA_ID_ID,
53
+ AUTH_MICROSOFT_ENTRA_ID_SECRET: authEnv.AUTH_MICROSOFT_ENTRA_ID_SECRET,
54
+ AUTH_MICROSOFT_ID: authEnv.AUTH_MICROSOFT_ID,
55
+ AUTH_MICROSOFT_SECRET: authEnv.AUTH_MICROSOFT_SECRET,
56
+ AZURE_AD_CLIENT_ID: authEnv.AZURE_AD_CLIENT_ID,
57
+ AZURE_AD_CLIENT_SECRET: authEnv.AZURE_AD_CLIENT_SECRET,
58
+ }
59
+ : false;
60
+ },
61
+ id: 'microsoft',
62
+ type: 'builtin',
63
+ };
64
+
65
+ export default provider;
@@ -0,0 +1,37 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ const provider: GenericProviderDefinition<{
7
+ AUTH_OKTA_ID: string;
8
+ AUTH_OKTA_ISSUER: string;
9
+ AUTH_OKTA_SECRET: string;
10
+ }> = {
11
+ build: (env) =>
12
+ buildOidcConfig({
13
+ clientId: env.AUTH_OKTA_ID,
14
+ clientSecret: env.AUTH_OKTA_SECRET,
15
+ issuer: env.AUTH_OKTA_ISSUER,
16
+ overrides: {
17
+ mapProfileToUser: (profile) => ({
18
+ email: profile.email,
19
+ name: profile.name ?? profile.preferred_username ?? profile.email ?? profile.sub,
20
+ }),
21
+ },
22
+ providerId: 'okta',
23
+ }),
24
+ checkEnvs: () => {
25
+ return !!(authEnv.AUTH_OKTA_ID && authEnv.AUTH_OKTA_SECRET && authEnv.AUTH_OKTA_ISSUER)
26
+ ? {
27
+ AUTH_OKTA_ID: authEnv.AUTH_OKTA_ID,
28
+ AUTH_OKTA_ISSUER: authEnv.AUTH_OKTA_ISSUER,
29
+ AUTH_OKTA_SECRET: authEnv.AUTH_OKTA_SECRET,
30
+ }
31
+ : false;
32
+ },
33
+ id: 'okta',
34
+ type: 'generic',
35
+ };
36
+
37
+ export default provider;
@@ -0,0 +1,140 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import type { GenericProviderDefinition } from '../types';
4
+
5
+ const WECHAT_AUTHORIZATION_URL = 'https://open.weixin.qq.com/connect/qrconnect';
6
+ const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
7
+ const WECHAT_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
8
+
9
+ type WeChatTokenResponse = {
10
+ access_token?: string;
11
+ errcode?: number;
12
+ errmsg?: string;
13
+ expires_in?: number;
14
+ openid?: string;
15
+ refresh_token?: string;
16
+ scope?: string;
17
+ token_type?: string;
18
+ unionid?: string;
19
+ };
20
+
21
+ const parseWechatScopes = (scope: string | undefined) =>
22
+ scope ? scope.split(' ').filter(Boolean) : [];
23
+
24
+ const provider: GenericProviderDefinition<{
25
+ AUTH_WECHAT_ID: string;
26
+ AUTH_WECHAT_SECRET: string;
27
+ }> = {
28
+ build: (env) => {
29
+ const clientId = env.AUTH_WECHAT_ID;
30
+ const clientSecret = env.AUTH_WECHAT_SECRET;
31
+
32
+ return {
33
+ authorizationUrl: WECHAT_AUTHORIZATION_URL,
34
+ authorizationUrlParams: {
35
+ appid: clientId,
36
+ response_type: 'code',
37
+ scope: 'snsapi_login',
38
+ },
39
+ clientId,
40
+ clientSecret,
41
+ /**
42
+ * WeChat uses a non-standard token endpoint (GET with appid/secret/code)
43
+ * and returns openid/unionid alongside tokens, so we exchange the code
44
+ * manually instead of proxying through a custom API route.
45
+ */
46
+ getToken: async ({ code }) => {
47
+ const tokenUrl = new URL(WECHAT_TOKEN_URL);
48
+ tokenUrl.searchParams.set('appid', clientId);
49
+ tokenUrl.searchParams.set('secret', clientSecret);
50
+ tokenUrl.searchParams.set('code', code);
51
+ tokenUrl.searchParams.set('grant_type', 'authorization_code');
52
+
53
+ const response = await fetch(tokenUrl, { cache: 'no-store' });
54
+ const data = (await response.json()) as WeChatTokenResponse;
55
+
56
+ if (!response.ok || data.errcode) {
57
+ throw new Error(data.errmsg ?? 'Failed to fetch WeChat OAuth token');
58
+ }
59
+
60
+ if (!data.access_token || !data.openid) {
61
+ throw new Error('WeChat token response is missing required fields');
62
+ }
63
+
64
+ return {
65
+ accessToken: data.access_token,
66
+ accessTokenExpiresAt: data.expires_in
67
+ ? new Date(Date.now() + data.expires_in * 1000)
68
+ : undefined,
69
+ expiresIn: data.expires_in,
70
+ raw: data,
71
+ refreshToken: data.refresh_token,
72
+ refreshTokenExpiresAt: undefined,
73
+ scopes: parseWechatScopes(data.scope),
74
+ tokenType: data.token_type ?? 'Bearer',
75
+ };
76
+ },
77
+ /**
78
+ * Use openid/unionid returned in the token response; no custom scope encoding needed.
79
+ */
80
+ getUserInfo: async (tokens) => {
81
+ const accessToken = tokens.accessToken;
82
+ const openId = (tokens as { raw?: WeChatTokenResponse }).raw?.openid;
83
+ const unionId = (tokens as { raw?: WeChatTokenResponse }).raw?.unionid;
84
+
85
+ if (!accessToken || !openId) {
86
+ return null;
87
+ }
88
+
89
+ const url = new URL(WECHAT_USERINFO_URL);
90
+ url.searchParams.set('access_token', accessToken);
91
+ url.searchParams.set('openid', openId);
92
+ url.searchParams.set('lang', 'zh_CN');
93
+
94
+ const response = await fetch(url, { cache: 'no-store' });
95
+ if (!response.ok) {
96
+ return null;
97
+ }
98
+
99
+ const profile = (await response.json()) as {
100
+ headimgurl?: string;
101
+ nickname?: string;
102
+ unionid?: string;
103
+ };
104
+
105
+ const finalUnionId = unionId ?? profile.unionid ?? openId;
106
+ const syntheticEmail = `${finalUnionId}@wechat.lobehub`;
107
+
108
+ return {
109
+ email: syntheticEmail,
110
+ emailVerified: false,
111
+ id: finalUnionId,
112
+ image: profile.headimgurl,
113
+ name: profile.nickname ?? finalUnionId,
114
+ ...profile,
115
+ };
116
+ },
117
+
118
+ pkce: false,
119
+
120
+ providerId: 'wechat',
121
+
122
+ responseMode: 'query',
123
+
124
+ scopes: ['snsapi_login'],
125
+ };
126
+ },
127
+
128
+ checkEnvs: () => {
129
+ return !!(authEnv.AUTH_WECHAT_ID && authEnv.AUTH_WECHAT_SECRET)
130
+ ? {
131
+ AUTH_WECHAT_ID: authEnv.AUTH_WECHAT_ID,
132
+ AUTH_WECHAT_SECRET: authEnv.AUTH_WECHAT_SECRET,
133
+ }
134
+ : false;
135
+ },
136
+ id: 'wechat',
137
+ type: 'generic',
138
+ };
139
+
140
+ export default provider;