@caffeinebounce/identity 0.12.1

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.
@@ -0,0 +1,110 @@
1
+ import { SupabaseClient, User, EmailOtpType } from '@supabase/supabase-js';
2
+ export { GeolocationInfo, generateSecureToken, getClientIP, getGeolocationFromIP, hashString } from '@caffeinebounce/shared-utils';
3
+
4
+ type AuthCallbackFlow = "oauth" | "otp";
5
+ type AuthCallbackHookErrorMode = "block" | "ignore";
6
+ type AuthCallbackRedirectTarget = string | URL | Response | null | undefined;
7
+ type AuthCallbackErrorSource = "oauth_error" | "code_exchange";
8
+ interface AuthCallbackHookContext {
9
+ /** Resolved Supabase client after callback exchange/verification succeeds */
10
+ supabase: SupabaseClient;
11
+ /** Authenticated Supabase user returned by getUser() */
12
+ user: User;
13
+ /** Original callback request */
14
+ request: Request;
15
+ /** Request origin used for final redirects */
16
+ origin: string;
17
+ /** Safe relative redirect path selected for this callback */
18
+ redirectPath: string;
19
+ /** Callback flow that established the session */
20
+ flow: AuthCallbackFlow;
21
+ /** OTP verification type, present for token_hash callbacks */
22
+ otpType?: EmailOtpType;
23
+ }
24
+ type AuthCallbackHook = (context: AuthCallbackHookContext) => void | Promise<void>;
25
+ type AuthCallbackSuccessRedirectResolver = (context: AuthCallbackHookContext) => AuthCallbackRedirectTarget | Promise<AuthCallbackRedirectTarget>;
26
+ interface AuthCallbackLinkingFlowContext {
27
+ /** Original callback request */
28
+ request: Request;
29
+ /** Request origin used for redirects */
30
+ origin: string;
31
+ /** Safe relative redirect path selected for this callback */
32
+ redirectPath: string;
33
+ }
34
+ type AuthCallbackLinkingFlowDetector = (context: AuthCallbackLinkingFlowContext) => boolean;
35
+ interface AuthCallbackLinkingErrorContext extends AuthCallbackLinkingFlowContext {
36
+ /** OAuth error code or code-exchange error name when available */
37
+ error: string;
38
+ /** OAuth error description, when supplied by the provider */
39
+ errorDescription: string | null;
40
+ /** Raw provider or exchange error message */
41
+ message: string;
42
+ /** Product-neutral fallback message selected by the handler */
43
+ defaultMessage: string;
44
+ /** Callback phase that produced the error */
45
+ source: AuthCallbackErrorSource;
46
+ }
47
+ type AuthCallbackLinkingErrorMessageResolver = (context: AuthCallbackLinkingErrorContext) => string | Promise<string>;
48
+ interface AuthCallbackConfig {
49
+ /** Async Supabase client factory (for server-side) */
50
+ createClient: () => Promise<SupabaseClient>;
51
+ /** Default redirect path after successful auth */
52
+ defaultRedirect?: string;
53
+ /** Sign-in page path for error redirects */
54
+ signInPath?: string;
55
+ /**
56
+ * Optional hook that runs after a successful auth callback and getUser().
57
+ * Use this for app-local profile hydration or other session-adjacent setup.
58
+ */
59
+ postAuthHook?: AuthCallbackHook;
60
+ /**
61
+ * How to handle postAuthHook failures.
62
+ * "block" redirects to signInPath with a generic setup error.
63
+ * "ignore" allows the normal success redirect to continue.
64
+ */
65
+ postAuthHookErrorMode?: AuthCallbackHookErrorMode;
66
+ /**
67
+ * Optional resolver for app-specific success redirects after auth succeeds.
68
+ * Use this for product-owned routing such as admin approval or subdomains.
69
+ */
70
+ resolveSuccessRedirect?: AuthCallbackSuccessRedirectResolver;
71
+ /**
72
+ * Optional detector for deciding whether an auth error came from account
73
+ * linking. Defaults to common profile/settings redirect paths.
74
+ */
75
+ isLinkingFlow?: AuthCallbackLinkingFlowDetector;
76
+ /**
77
+ * Optional resolver for product-specific account-linking error copy.
78
+ * Defaults remain product-neutral.
79
+ */
80
+ resolveLinkingErrorMessage?: AuthCallbackLinkingErrorMessageResolver;
81
+ }
82
+ /**
83
+ * createAuthCallbackHandler - Factory for OAuth/email callback route handler
84
+ *
85
+ * Creates a GET handler that exchanges authorization codes or verifies
86
+ * token_hash OTP links for sessions.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // app/(auth)/callback/route.ts
91
+ * import { createAuthCallbackHandler } from "@caffeinebounce/identity";
92
+ * import { createClient } from "@/lib/supabase/server";
93
+ *
94
+ * export const GET = createAuthCallbackHandler({
95
+ * createClient,
96
+ * defaultRedirect: "/dashboard",
97
+ * postAuthHook: async ({ user }) => {
98
+ * await ensureProfileRecord({
99
+ * userId: user.id,
100
+ * email: user.email ?? null,
101
+ * metadata: user.user_metadata,
102
+ * });
103
+ * },
104
+ * postAuthHookErrorMode: "ignore",
105
+ * });
106
+ * ```
107
+ */
108
+ declare function createAuthCallbackHandler({ createClient, defaultRedirect, signInPath, postAuthHook, postAuthHookErrorMode, resolveSuccessRedirect, isLinkingFlow, resolveLinkingErrorMessage, }: AuthCallbackConfig): (request: Request) => Promise<Response>;
109
+
110
+ export { type AuthCallbackConfig, type AuthCallbackErrorSource, type AuthCallbackFlow, type AuthCallbackHook, type AuthCallbackHookContext, type AuthCallbackHookErrorMode, type AuthCallbackLinkingErrorContext, type AuthCallbackLinkingErrorMessageResolver, type AuthCallbackLinkingFlowContext, type AuthCallbackLinkingFlowDetector, type AuthCallbackRedirectTarget, type AuthCallbackSuccessRedirectResolver, createAuthCallbackHandler };
@@ -0,0 +1,110 @@
1
+ import { SupabaseClient, User, EmailOtpType } from '@supabase/supabase-js';
2
+ export { GeolocationInfo, generateSecureToken, getClientIP, getGeolocationFromIP, hashString } from '@caffeinebounce/shared-utils';
3
+
4
+ type AuthCallbackFlow = "oauth" | "otp";
5
+ type AuthCallbackHookErrorMode = "block" | "ignore";
6
+ type AuthCallbackRedirectTarget = string | URL | Response | null | undefined;
7
+ type AuthCallbackErrorSource = "oauth_error" | "code_exchange";
8
+ interface AuthCallbackHookContext {
9
+ /** Resolved Supabase client after callback exchange/verification succeeds */
10
+ supabase: SupabaseClient;
11
+ /** Authenticated Supabase user returned by getUser() */
12
+ user: User;
13
+ /** Original callback request */
14
+ request: Request;
15
+ /** Request origin used for final redirects */
16
+ origin: string;
17
+ /** Safe relative redirect path selected for this callback */
18
+ redirectPath: string;
19
+ /** Callback flow that established the session */
20
+ flow: AuthCallbackFlow;
21
+ /** OTP verification type, present for token_hash callbacks */
22
+ otpType?: EmailOtpType;
23
+ }
24
+ type AuthCallbackHook = (context: AuthCallbackHookContext) => void | Promise<void>;
25
+ type AuthCallbackSuccessRedirectResolver = (context: AuthCallbackHookContext) => AuthCallbackRedirectTarget | Promise<AuthCallbackRedirectTarget>;
26
+ interface AuthCallbackLinkingFlowContext {
27
+ /** Original callback request */
28
+ request: Request;
29
+ /** Request origin used for redirects */
30
+ origin: string;
31
+ /** Safe relative redirect path selected for this callback */
32
+ redirectPath: string;
33
+ }
34
+ type AuthCallbackLinkingFlowDetector = (context: AuthCallbackLinkingFlowContext) => boolean;
35
+ interface AuthCallbackLinkingErrorContext extends AuthCallbackLinkingFlowContext {
36
+ /** OAuth error code or code-exchange error name when available */
37
+ error: string;
38
+ /** OAuth error description, when supplied by the provider */
39
+ errorDescription: string | null;
40
+ /** Raw provider or exchange error message */
41
+ message: string;
42
+ /** Product-neutral fallback message selected by the handler */
43
+ defaultMessage: string;
44
+ /** Callback phase that produced the error */
45
+ source: AuthCallbackErrorSource;
46
+ }
47
+ type AuthCallbackLinkingErrorMessageResolver = (context: AuthCallbackLinkingErrorContext) => string | Promise<string>;
48
+ interface AuthCallbackConfig {
49
+ /** Async Supabase client factory (for server-side) */
50
+ createClient: () => Promise<SupabaseClient>;
51
+ /** Default redirect path after successful auth */
52
+ defaultRedirect?: string;
53
+ /** Sign-in page path for error redirects */
54
+ signInPath?: string;
55
+ /**
56
+ * Optional hook that runs after a successful auth callback and getUser().
57
+ * Use this for app-local profile hydration or other session-adjacent setup.
58
+ */
59
+ postAuthHook?: AuthCallbackHook;
60
+ /**
61
+ * How to handle postAuthHook failures.
62
+ * "block" redirects to signInPath with a generic setup error.
63
+ * "ignore" allows the normal success redirect to continue.
64
+ */
65
+ postAuthHookErrorMode?: AuthCallbackHookErrorMode;
66
+ /**
67
+ * Optional resolver for app-specific success redirects after auth succeeds.
68
+ * Use this for product-owned routing such as admin approval or subdomains.
69
+ */
70
+ resolveSuccessRedirect?: AuthCallbackSuccessRedirectResolver;
71
+ /**
72
+ * Optional detector for deciding whether an auth error came from account
73
+ * linking. Defaults to common profile/settings redirect paths.
74
+ */
75
+ isLinkingFlow?: AuthCallbackLinkingFlowDetector;
76
+ /**
77
+ * Optional resolver for product-specific account-linking error copy.
78
+ * Defaults remain product-neutral.
79
+ */
80
+ resolveLinkingErrorMessage?: AuthCallbackLinkingErrorMessageResolver;
81
+ }
82
+ /**
83
+ * createAuthCallbackHandler - Factory for OAuth/email callback route handler
84
+ *
85
+ * Creates a GET handler that exchanges authorization codes or verifies
86
+ * token_hash OTP links for sessions.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * // app/(auth)/callback/route.ts
91
+ * import { createAuthCallbackHandler } from "@caffeinebounce/identity";
92
+ * import { createClient } from "@/lib/supabase/server";
93
+ *
94
+ * export const GET = createAuthCallbackHandler({
95
+ * createClient,
96
+ * defaultRedirect: "/dashboard",
97
+ * postAuthHook: async ({ user }) => {
98
+ * await ensureProfileRecord({
99
+ * userId: user.id,
100
+ * email: user.email ?? null,
101
+ * metadata: user.user_metadata,
102
+ * });
103
+ * },
104
+ * postAuthHookErrorMode: "ignore",
105
+ * });
106
+ * ```
107
+ */
108
+ declare function createAuthCallbackHandler({ createClient, defaultRedirect, signInPath, postAuthHook, postAuthHookErrorMode, resolveSuccessRedirect, isLinkingFlow, resolveLinkingErrorMessage, }: AuthCallbackConfig): (request: Request) => Promise<Response>;
109
+
110
+ export { type AuthCallbackConfig, type AuthCallbackErrorSource, type AuthCallbackFlow, type AuthCallbackHook, type AuthCallbackHookContext, type AuthCallbackHookErrorMode, type AuthCallbackLinkingErrorContext, type AuthCallbackLinkingErrorMessageResolver, type AuthCallbackLinkingFlowContext, type AuthCallbackLinkingFlowDetector, type AuthCallbackRedirectTarget, type AuthCallbackSuccessRedirectResolver, createAuthCallbackHandler };
package/dist/server.js ADDED
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ var server = require('next/server');
4
+ var sharedUtils = require('@caffeinebounce/shared-utils');
5
+
6
+ // src/handlers/callback.ts
7
+ var POST_AUTH_HOOK_ERROR_MESSAGE = "Authentication completed, but setup failed. Please try again.";
8
+ var DEFAULT_LINKING_ACCOUNT_ERROR_MESSAGE = "This account is already connected to another account. Each external account can only be linked to one account.";
9
+ var EMAIL_OTP_TYPES = [
10
+ "signup",
11
+ "invite",
12
+ "magiclink",
13
+ "recovery",
14
+ "email_change",
15
+ "email"
16
+ ];
17
+ function getSafeRedirectPath(candidate, fallback) {
18
+ if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
19
+ return candidate;
20
+ }
21
+ return fallback;
22
+ }
23
+ function getEmailOtpType(value) {
24
+ if (!value) {
25
+ return null;
26
+ }
27
+ return EMAIL_OTP_TYPES.includes(value) ? value : null;
28
+ }
29
+ function isDefaultLinkingFlow({
30
+ redirectPath
31
+ }) {
32
+ return redirectPath.includes("/profile") || redirectPath.includes("/settings");
33
+ }
34
+ function isAlreadyLinkedAccountError(message) {
35
+ return message.includes("already linked") || message.includes("identity already exists") || message.includes("already registered");
36
+ }
37
+ function getDefaultLinkingErrorMessage(message) {
38
+ return isAlreadyLinkedAccountError(message) ? DEFAULT_LINKING_ACCOUNT_ERROR_MESSAGE : message;
39
+ }
40
+ async function createLinkingErrorRedirect({
41
+ request,
42
+ origin,
43
+ redirectPath,
44
+ error,
45
+ errorDescription,
46
+ message,
47
+ source,
48
+ isLinkingFlow,
49
+ resolveLinkingErrorMessage
50
+ }) {
51
+ const linkingFlowContext = { request, origin, redirectPath };
52
+ if (!isLinkingFlow(linkingFlowContext)) {
53
+ return null;
54
+ }
55
+ const defaultMessage = getDefaultLinkingErrorMessage(message);
56
+ const userMessage = await resolveLinkingErrorMessage?.({
57
+ ...linkingFlowContext,
58
+ error,
59
+ errorDescription,
60
+ message,
61
+ defaultMessage,
62
+ source
63
+ }) ?? defaultMessage;
64
+ const nextUrl = new URL(redirectPath, origin);
65
+ nextUrl.searchParams.set("link_error", userMessage);
66
+ return server.NextResponse.redirect(nextUrl.toString());
67
+ }
68
+ async function runPostAuthHook({
69
+ postAuthHook,
70
+ postAuthHookErrorMode,
71
+ supabase,
72
+ user,
73
+ request,
74
+ origin,
75
+ redirectPath,
76
+ flow,
77
+ otpType,
78
+ signInPath
79
+ }) {
80
+ if (!postAuthHook) {
81
+ return null;
82
+ }
83
+ try {
84
+ await postAuthHook({
85
+ supabase,
86
+ user,
87
+ request,
88
+ origin,
89
+ redirectPath,
90
+ flow,
91
+ otpType
92
+ });
93
+ return null;
94
+ } catch {
95
+ if (postAuthHookErrorMode === "ignore") {
96
+ return null;
97
+ }
98
+ return server.NextResponse.redirect(
99
+ `${origin}${signInPath}?error=${encodeURIComponent(POST_AUTH_HOOK_ERROR_MESSAGE)}`
100
+ );
101
+ }
102
+ }
103
+ function createRedirectResponse(target, origin) {
104
+ if (!target) {
105
+ return null;
106
+ }
107
+ if (target instanceof Response) {
108
+ return target;
109
+ }
110
+ if (target instanceof URL) {
111
+ return server.NextResponse.redirect(target.toString());
112
+ }
113
+ if (target.startsWith("/") && !target.startsWith("//")) {
114
+ return server.NextResponse.redirect(`${origin}${target}`);
115
+ }
116
+ return null;
117
+ }
118
+ async function redirectAfterSuccessfulAuth({
119
+ supabase,
120
+ request,
121
+ origin,
122
+ redirectPath,
123
+ flow,
124
+ otpType,
125
+ postAuthHook,
126
+ postAuthHookErrorMode,
127
+ signInPath,
128
+ resolveSuccessRedirect
129
+ }) {
130
+ const {
131
+ data: { user }
132
+ } = await supabase.auth.getUser();
133
+ if (!user) {
134
+ return server.NextResponse.redirect(`${origin}${redirectPath}`);
135
+ }
136
+ const postAuthHookRedirect = await runPostAuthHook({
137
+ postAuthHook,
138
+ postAuthHookErrorMode,
139
+ supabase,
140
+ user,
141
+ request,
142
+ origin,
143
+ redirectPath,
144
+ flow,
145
+ otpType,
146
+ signInPath
147
+ });
148
+ if (postAuthHookRedirect) {
149
+ return postAuthHookRedirect;
150
+ }
151
+ const customRedirect = await resolveSuccessRedirect?.({
152
+ supabase,
153
+ user,
154
+ request,
155
+ origin,
156
+ redirectPath,
157
+ flow,
158
+ otpType
159
+ });
160
+ const customRedirectResponse = createRedirectResponse(customRedirect, origin);
161
+ if (customRedirectResponse) {
162
+ return customRedirectResponse;
163
+ }
164
+ return server.NextResponse.redirect(`${origin}${redirectPath}`);
165
+ }
166
+ function createAuthCallbackHandler({
167
+ createClient,
168
+ defaultRedirect = "/dashboard",
169
+ signInPath = "/signin",
170
+ postAuthHook,
171
+ postAuthHookErrorMode = "block",
172
+ resolveSuccessRedirect,
173
+ isLinkingFlow = isDefaultLinkingFlow,
174
+ resolveLinkingErrorMessage
175
+ }) {
176
+ return async function GET(request) {
177
+ const requestUrl = new URL(request.url);
178
+ const code = requestUrl.searchParams.get("code");
179
+ const tokenHash = requestUrl.searchParams.get("token_hash");
180
+ const rawOtpType = requestUrl.searchParams.get("type");
181
+ const otpType = getEmailOtpType(rawOtpType);
182
+ const next = getSafeRedirectPath(
183
+ requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
184
+ defaultRedirect
185
+ );
186
+ const origin = requestUrl.origin;
187
+ const error = requestUrl.searchParams.get("error");
188
+ const errorDescription = requestUrl.searchParams.get("error_description");
189
+ if (error) {
190
+ const message = errorDescription || error;
191
+ const linkingErrorRedirect = await createLinkingErrorRedirect({
192
+ request,
193
+ origin,
194
+ redirectPath: next,
195
+ error,
196
+ errorDescription,
197
+ message,
198
+ source: "oauth_error",
199
+ isLinkingFlow,
200
+ resolveLinkingErrorMessage
201
+ });
202
+ if (linkingErrorRedirect) {
203
+ return linkingErrorRedirect;
204
+ }
205
+ return server.NextResponse.redirect(
206
+ `${origin}${signInPath}?error=${encodeURIComponent(message)}`
207
+ );
208
+ }
209
+ if (code) {
210
+ const supabase = await createClient();
211
+ const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
212
+ if (!exchangeError) {
213
+ return redirectAfterSuccessfulAuth({
214
+ supabase,
215
+ request,
216
+ origin,
217
+ redirectPath: next,
218
+ flow: "oauth",
219
+ postAuthHook,
220
+ postAuthHookErrorMode,
221
+ signInPath,
222
+ resolveSuccessRedirect
223
+ });
224
+ }
225
+ const errorMessage = exchangeError.message;
226
+ const linkingErrorRedirect = await createLinkingErrorRedirect({
227
+ request,
228
+ origin,
229
+ redirectPath: next,
230
+ error: exchangeError.name || "code_exchange",
231
+ errorDescription: null,
232
+ message: errorMessage,
233
+ source: "code_exchange",
234
+ isLinkingFlow,
235
+ resolveLinkingErrorMessage
236
+ });
237
+ if (linkingErrorRedirect) {
238
+ return linkingErrorRedirect;
239
+ }
240
+ return server.NextResponse.redirect(
241
+ `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
242
+ );
243
+ }
244
+ if (tokenHash || rawOtpType) {
245
+ if (!tokenHash || !otpType) {
246
+ return server.NextResponse.redirect(
247
+ `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
248
+ );
249
+ }
250
+ const supabase = await createClient();
251
+ const { error: verifyError } = await supabase.auth.verifyOtp({
252
+ token_hash: tokenHash,
253
+ type: otpType
254
+ });
255
+ if (!verifyError) {
256
+ return redirectAfterSuccessfulAuth({
257
+ supabase,
258
+ request,
259
+ origin,
260
+ redirectPath: next,
261
+ flow: "otp",
262
+ otpType,
263
+ postAuthHook,
264
+ postAuthHookErrorMode,
265
+ signInPath,
266
+ resolveSuccessRedirect
267
+ });
268
+ }
269
+ return server.NextResponse.redirect(
270
+ `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
271
+ );
272
+ }
273
+ return server.NextResponse.redirect(
274
+ `${origin}${signInPath}?error=No authorization code received`
275
+ );
276
+ };
277
+ }
278
+
279
+ Object.defineProperty(exports, "generateSecureToken", {
280
+ enumerable: true,
281
+ get: function () { return sharedUtils.generateSecureToken; }
282
+ });
283
+ Object.defineProperty(exports, "getClientIP", {
284
+ enumerable: true,
285
+ get: function () { return sharedUtils.getClientIP; }
286
+ });
287
+ Object.defineProperty(exports, "getGeolocationFromIP", {
288
+ enumerable: true,
289
+ get: function () { return sharedUtils.getGeolocationFromIP; }
290
+ });
291
+ Object.defineProperty(exports, "hashString", {
292
+ enumerable: true,
293
+ get: function () { return sharedUtils.hashString; }
294
+ });
295
+ exports.createAuthCallbackHandler = createAuthCallbackHandler;