@hammadj/better-auth-sso 1.5.0-beta.9

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 (42) hide show
  1. package/.turbo/turbo-build.log +116 -0
  2. package/LICENSE.md +20 -0
  3. package/dist/client.d.mts +10 -0
  4. package/dist/client.mjs +15 -0
  5. package/dist/client.mjs.map +1 -0
  6. package/dist/index.d.mts +738 -0
  7. package/dist/index.mjs +2953 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +87 -0
  10. package/src/client.ts +29 -0
  11. package/src/constants.ts +58 -0
  12. package/src/domain-verification.test.ts +551 -0
  13. package/src/index.ts +265 -0
  14. package/src/linking/index.ts +2 -0
  15. package/src/linking/org-assignment.test.ts +325 -0
  16. package/src/linking/org-assignment.ts +176 -0
  17. package/src/linking/types.ts +10 -0
  18. package/src/oidc/discovery.test.ts +1157 -0
  19. package/src/oidc/discovery.ts +494 -0
  20. package/src/oidc/errors.ts +92 -0
  21. package/src/oidc/index.ts +31 -0
  22. package/src/oidc/types.ts +219 -0
  23. package/src/oidc.test.ts +688 -0
  24. package/src/providers.test.ts +1326 -0
  25. package/src/routes/domain-verification.ts +275 -0
  26. package/src/routes/providers.ts +565 -0
  27. package/src/routes/schemas.ts +96 -0
  28. package/src/routes/sso.ts +2750 -0
  29. package/src/saml/algorithms.test.ts +449 -0
  30. package/src/saml/algorithms.ts +338 -0
  31. package/src/saml/assertions.test.ts +239 -0
  32. package/src/saml/assertions.ts +62 -0
  33. package/src/saml/index.ts +13 -0
  34. package/src/saml/parser.ts +56 -0
  35. package/src/saml-state.ts +78 -0
  36. package/src/saml.test.ts +4319 -0
  37. package/src/types.ts +365 -0
  38. package/src/utils.test.ts +103 -0
  39. package/src/utils.ts +81 -0
  40. package/tsconfig.json +14 -0
  41. package/tsdown.config.ts +9 -0
  42. package/vitest.config.ts +3 -0
@@ -0,0 +1,176 @@
1
+ import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
2
+ import type { SSOOptions, SSOProvider } from "../types";
3
+ import { domainMatches } from "../utils";
4
+ import type { NormalizedSSOProfile } from "./types";
5
+
6
+ export interface OrganizationProvisioningOptions {
7
+ disabled?: boolean;
8
+ defaultRole?: "member" | "admin";
9
+ getRole?: (data: {
10
+ user: User & Record<string, any>;
11
+ userInfo: Record<string, any>;
12
+ token?: OAuth2Tokens;
13
+ provider: SSOProvider<SSOOptions>;
14
+ }) => Promise<"member" | "admin">;
15
+ }
16
+
17
+ export interface AssignOrganizationFromProviderOptions {
18
+ user: User;
19
+ profile: NormalizedSSOProfile;
20
+ provider: SSOProvider<SSOOptions>;
21
+ token?: OAuth2Tokens;
22
+ provisioningOptions?: OrganizationProvisioningOptions;
23
+ }
24
+
25
+ /**
26
+ * Assigns a user to an organization based on the SSO provider's organizationId.
27
+ * Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
28
+ */
29
+ export async function assignOrganizationFromProvider(
30
+ ctx: GenericEndpointContext,
31
+ options: AssignOrganizationFromProviderOptions,
32
+ ): Promise<void> {
33
+ const { user, profile, provider, token, provisioningOptions } = options;
34
+
35
+ if (!provider.organizationId) {
36
+ return;
37
+ }
38
+
39
+ if (provisioningOptions?.disabled) {
40
+ return;
41
+ }
42
+
43
+ if (!ctx.context.hasPlugin("organization")) {
44
+ return;
45
+ }
46
+
47
+ const isAlreadyMember = await ctx.context.adapter.findOne({
48
+ model: "member",
49
+ where: [
50
+ { field: "organizationId", value: provider.organizationId },
51
+ { field: "userId", value: user.id },
52
+ ],
53
+ });
54
+
55
+ if (isAlreadyMember) {
56
+ return;
57
+ }
58
+
59
+ const role = provisioningOptions?.getRole
60
+ ? await provisioningOptions.getRole({
61
+ user,
62
+ userInfo: profile.rawAttributes || {},
63
+ token,
64
+ provider,
65
+ })
66
+ : provisioningOptions?.defaultRole || "member";
67
+
68
+ await ctx.context.adapter.create({
69
+ model: "member",
70
+ data: {
71
+ organizationId: provider.organizationId,
72
+ userId: user.id,
73
+ role,
74
+ createdAt: new Date(),
75
+ },
76
+ });
77
+ }
78
+
79
+ export interface AssignOrganizationByDomainOptions {
80
+ user: User;
81
+ provisioningOptions?: OrganizationProvisioningOptions;
82
+ domainVerification?: {
83
+ enabled?: boolean;
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Assigns a user to an organization based on their email domain.
89
+ * Looks up SSO providers that match the user's email domain and assigns
90
+ * the user to the associated organization.
91
+ *
92
+ * This enables domain-based org assignment for non-SSO sign-in methods
93
+ * (e.g., Google OAuth with @acme.com email gets added to Acme's org).
94
+ */
95
+ export async function assignOrganizationByDomain(
96
+ ctx: GenericEndpointContext,
97
+ options: AssignOrganizationByDomainOptions,
98
+ ): Promise<void> {
99
+ const { user, provisioningOptions, domainVerification } = options;
100
+
101
+ if (provisioningOptions?.disabled) {
102
+ return;
103
+ }
104
+
105
+ if (!ctx.context.hasPlugin("organization")) {
106
+ return;
107
+ }
108
+
109
+ const domain = user.email.split("@")[1];
110
+ if (!domain) {
111
+ return;
112
+ }
113
+
114
+ // Support comma-separated domains for multi-domain SSO
115
+ // First try exact match (fast path)
116
+ const whereClause: { field: string; value: string | boolean }[] = [
117
+ { field: "domain", value: domain },
118
+ ];
119
+
120
+ if (domainVerification?.enabled) {
121
+ whereClause.push({ field: "domainVerified", value: true });
122
+ }
123
+
124
+ let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
125
+ model: "ssoProvider",
126
+ where: whereClause,
127
+ });
128
+
129
+ // If not found, search all providers for comma-separated domain match
130
+ if (!ssoProvider) {
131
+ const allProviders = await ctx.context.adapter.findMany<
132
+ SSOProvider<SSOOptions>
133
+ >({
134
+ model: "ssoProvider",
135
+ where: domainVerification?.enabled
136
+ ? [{ field: "domainVerified", value: true }]
137
+ : [],
138
+ });
139
+ ssoProvider =
140
+ allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
141
+ }
142
+
143
+ if (!ssoProvider || !ssoProvider.organizationId) {
144
+ return;
145
+ }
146
+
147
+ const isAlreadyMember = await ctx.context.adapter.findOne({
148
+ model: "member",
149
+ where: [
150
+ { field: "organizationId", value: ssoProvider.organizationId },
151
+ { field: "userId", value: user.id },
152
+ ],
153
+ });
154
+
155
+ if (isAlreadyMember) {
156
+ return;
157
+ }
158
+
159
+ const role = provisioningOptions?.getRole
160
+ ? await provisioningOptions.getRole({
161
+ user,
162
+ userInfo: {},
163
+ provider: ssoProvider,
164
+ })
165
+ : provisioningOptions?.defaultRole || "member";
166
+
167
+ await ctx.context.adapter.create({
168
+ model: "member",
169
+ data: {
170
+ organizationId: ssoProvider.organizationId,
171
+ userId: user.id,
172
+ role,
173
+ createdAt: new Date(),
174
+ },
175
+ });
176
+ }
@@ -0,0 +1,10 @@
1
+ export interface NormalizedSSOProfile {
2
+ providerType: "saml" | "oidc";
3
+ providerId: string;
4
+ accountId: string;
5
+ email: string;
6
+ emailVerified: boolean;
7
+ name?: string;
8
+ image?: string;
9
+ rawAttributes?: Record<string, unknown>;
10
+ }