@selvajs/supabase-provider 0.14.4-beta.0 → 0.15.0-beta.2
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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
- package/dist/auth/SupabaseAuthProvider.js +7 -14
- package/dist/auth/SupabaseAuthProvider.js.map +1 -1
- package/dist/data/SupabaseComputeServerStore.d.ts +14 -3
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
- package/dist/data/SupabaseComputeServerStore.js +67 -16
- package/dist/data/SupabaseComputeServerStore.js.map +1 -1
- package/dist/data/SupabaseDataProvider.d.ts +36 -8
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
- package/dist/data/SupabaseDataProvider.js +119 -15
- package/dist/data/SupabaseDataProvider.js.map +1 -1
- package/dist/data/SupabaseDefinitionStore.d.ts +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
- package/dist/data/SupabaseDefinitionStore.js +41 -4
- package/dist/data/SupabaseDefinitionStore.js.map +1 -1
- package/dist/data/SupabaseEventSink.d.ts +5 -2
- package/dist/data/SupabaseEventSink.d.ts.map +1 -1
- package/dist/data/SupabaseEventSink.js +20 -3
- package/dist/data/SupabaseEventSink.js.map +1 -1
- package/dist/data/SupabaseSolveMetricSink.d.ts +49 -4
- package/dist/data/SupabaseSolveMetricSink.d.ts.map +1 -1
- package/dist/data/SupabaseSolveMetricSink.js +109 -19
- package/dist/data/SupabaseSolveMetricSink.js.map +1 -1
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -0
- package/dist/data/index.js.map +1 -1
- package/dist/data/migrationHead.d.ts +14 -0
- package/dist/data/migrationHead.d.ts.map +1 -0
- package/dist/data/migrationHead.js +14 -0
- package/dist/data/migrationHead.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts +5 -3
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -1
- package/dist/permissions/SupabasePlatformPermissionStore.js +17 -30
- package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -1
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
- package/dist/storage/SupabaseStorageProvider.js +3 -0
- package/dist/storage/SupabaseStorageProvider.js.map +1 -1
- package/package.json +23 -14
- package/supabase/migrations/20260712120000_user_profiles_disabled_and_gin.sql +95 -0
- package/supabase/migrations/20260712123000_selva_migration_head_fn.sql +38 -0
- package/supabase/migrations/20260713120000_selva_next_version_number.sql +59 -0
- package/supabase/migrations/20260713140000_selva_solve_cache_limit.sql +15 -0
- package/supabase/migrations/20260716120000_definition_status_and_audit_version.sql +36 -0
- package/supabase/migrations/20260716130000_erase_user_audit_email.sql +36 -0
- package/supabase/migrations/20260717120000_compute_server_has_api_key.sql +20 -0
- package/dist/auth/__tests__/conformance.test.d.ts +0 -2
- package/dist/auth/__tests__/conformance.test.d.ts.map +0 -1
- package/dist/auth/__tests__/conformance.test.js +0 -76
- package/dist/auth/__tests__/conformance.test.js.map +0 -1
- package/dist/auth/__tests__/token-verification.test.d.ts +0 -13
- package/dist/auth/__tests__/token-verification.test.d.ts.map +0 -1
- package/dist/auth/__tests__/token-verification.test.js +0 -135
- package/dist/auth/__tests__/token-verification.test.js.map +0 -1
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/compute-server-conformance.test.js +0 -45
- package/dist/data/__tests__/compute-server-conformance.test.js.map +0 -1
- package/dist/data/__tests__/compute-server-encryption.test.d.ts +0 -12
- package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +0 -1
- package/dist/data/__tests__/compute-server-encryption.test.js +0 -143
- package/dist/data/__tests__/compute-server-encryption.test.js.map +0 -1
- package/dist/data/__tests__/definition-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/definition-conformance.test.js +0 -85
- package/dist/data/__tests__/definition-conformance.test.js.map +0 -1
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/event-sink-conformance.test.js +0 -27
- package/dist/data/__tests__/event-sink-conformance.test.js.map +0 -1
- package/dist/data/__tests__/invite-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/invite-conformance.test.js +0 -45
- package/dist/data/__tests__/invite-conformance.test.js.map +0 -1
- package/dist/data/__tests__/org-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/org-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/org-conformance.test.js +0 -29
- package/dist/data/__tests__/org-conformance.test.js.map +0 -1
- package/dist/data/__tests__/project-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/project-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/project-conformance.test.js +0 -57
- package/dist/data/__tests__/project-conformance.test.js.map +0 -1
- package/dist/data/__tests__/share-link-concurrency.test.d.ts +0 -22
- package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +0 -1
- package/dist/data/__tests__/share-link-concurrency.test.js +0 -115
- package/dist/data/__tests__/share-link-concurrency.test.js.map +0 -1
- package/dist/data/__tests__/share-link-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/share-link-conformance.test.js +0 -88
- package/dist/data/__tests__/share-link-conformance.test.js.map +0 -1
- package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts +0 -2
- package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts.map +0 -1
- package/dist/data/__tests__/solve-metric-sink-conformance.test.js +0 -52
- package/dist/data/__tests__/solve-metric-sink-conformance.test.js.map +0 -1
- package/dist/data/__tests__/test-helpers.d.ts +0 -52
- package/dist/data/__tests__/test-helpers.d.ts.map +0 -1
- package/dist/data/__tests__/test-helpers.js +0 -177
- package/dist/data/__tests__/test-helpers.js.map +0 -1
- package/dist/permissions/__tests__/conformance.test.d.ts +0 -2
- package/dist/permissions/__tests__/conformance.test.d.ts.map +0 -1
- package/dist/permissions/__tests__/conformance.test.js +0 -27
- package/dist/permissions/__tests__/conformance.test.js.map +0 -1
- package/dist/storage/__tests__/conformance.test.d.ts +0 -2
- package/dist/storage/__tests__/conformance.test.d.ts.map +0 -1
- package/dist/storage/__tests__/conformance.test.js +0 -149
- package/dist/storage/__tests__/conformance.test.js.map +0 -1
- package/dist/userProfile/__tests__/conformance.test.d.ts +0 -2
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +0 -1
- package/dist/userProfile/__tests__/conformance.test.js +0 -38
- package/dist/userProfile/__tests__/conformance.test.js.map +0 -1
- package/src/auth/SupabaseAuthProvider.ts +0 -600
- package/src/auth/__tests__/conformance.test.ts +0 -78
- package/src/auth/__tests__/token-verification.test.ts +0 -158
- package/src/auth/index.ts +0 -2
- package/src/cli/sync-migrations.ts +0 -133
- package/src/data/SupabaseAuditQuery.ts +0 -103
- package/src/data/SupabaseComputeServerStore.ts +0 -382
- package/src/data/SupabaseDataProvider.ts +0 -153
- package/src/data/SupabaseDefinitionStore.ts +0 -527
- package/src/data/SupabaseEventSink.ts +0 -30
- package/src/data/SupabaseInviteStore.ts +0 -171
- package/src/data/SupabaseOrgStore.ts +0 -508
- package/src/data/SupabaseProjectStore.ts +0 -431
- package/src/data/SupabaseShareLinkStore.ts +0 -176
- package/src/data/SupabaseSolveMetricSink.ts +0 -38
- package/src/data/__tests__/compute-server-conformance.test.ts +0 -46
- package/src/data/__tests__/compute-server-encryption.test.ts +0 -173
- package/src/data/__tests__/definition-conformance.test.ts +0 -89
- package/src/data/__tests__/event-sink-conformance.test.ts +0 -32
- package/src/data/__tests__/invite-conformance.test.ts +0 -46
- package/src/data/__tests__/org-conformance.test.ts +0 -30
- package/src/data/__tests__/project-conformance.test.ts +0 -57
- package/src/data/__tests__/share-link-concurrency.test.ts +0 -124
- package/src/data/__tests__/share-link-conformance.test.ts +0 -87
- package/src/data/__tests__/solve-metric-sink-conformance.test.ts +0 -59
- package/src/data/__tests__/test-helpers.ts +0 -188
- package/src/data/client.ts +0 -173
- package/src/data/errors.ts +0 -45
- package/src/data/index.ts +0 -29
- package/src/data/pagination.ts +0 -68
- package/src/data/query.ts +0 -22
- package/src/data/rowStamp.ts +0 -48
- package/src/index.ts +0 -42
- package/src/permissions/SupabasePlatformPermissionStore.ts +0 -161
- package/src/permissions/__tests__/conformance.test.ts +0 -28
- package/src/permissions/index.ts +0 -1
- package/src/storage/SupabaseStorageProvider.ts +0 -182
- package/src/storage/__tests__/conformance.test.ts +0 -152
- package/src/storage/index.ts +0 -2
- package/src/userProfile/SupabaseUserProfileProvider.ts +0 -177
- package/src/userProfile/__tests__/conformance.test.ts +0 -39
- package/src/userProfile/index.ts +0 -1
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { describe, beforeEach, it } from 'vitest';
|
|
2
|
-
import { runUserProfileStoreConformance } from '@selvajs/platform/testing';
|
|
3
|
-
import { SupabaseUserProfileProvider } from '../SupabaseUserProfileProvider.js';
|
|
4
|
-
import { readEnv, resetAllData } from '../../data/__tests__/test-helpers.js';
|
|
5
|
-
const envCtx = readEnv();
|
|
6
|
-
if (!envCtx) {
|
|
7
|
-
describe.skip('SupabaseUserProfileProvider (skipped: no live stack)', () => {
|
|
8
|
-
it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => { });
|
|
9
|
-
});
|
|
10
|
-
}
|
|
11
|
-
else {
|
|
12
|
-
describe('SupabaseUserProfileProvider', () => {
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
await resetAllData(envCtx);
|
|
15
|
-
});
|
|
16
|
-
runUserProfileStoreConformance({
|
|
17
|
-
name: 'SupabaseUserProfileProvider',
|
|
18
|
-
createStore: async () => {
|
|
19
|
-
const store = new SupabaseUserProfileProvider(envCtx.bundle);
|
|
20
|
-
const seedUser = async (email) => {
|
|
21
|
-
const { data, error } = await envCtx.adminClient.auth.admin.createUser({
|
|
22
|
-
email,
|
|
23
|
-
password: 'conformance-test-password-1234',
|
|
24
|
-
email_confirm: true
|
|
25
|
-
});
|
|
26
|
-
if (error)
|
|
27
|
-
throw error;
|
|
28
|
-
return {
|
|
29
|
-
id: data.user.id,
|
|
30
|
-
email: data.user.email
|
|
31
|
-
};
|
|
32
|
-
};
|
|
33
|
-
return { store, seedUser };
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
//# sourceMappingURL=conformance.test.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"conformance.test.js","sourceRoot":"","sources":["../../../src/userProfile/__tests__/conformance.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,8BAA8B,EAAE,MAAM,2BAA2B,CAAC;AAE3E,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAE7E,MAAM,MAAM,GAAG,OAAO,EAAE,CAAC;AAEzB,IAAI,CAAC,MAAM,EAAE,CAAC;IACb,QAAQ,CAAC,IAAI,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC1E,EAAE,CAAC,uFAAuF,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACvG,CAAC,CAAC,CAAC;AACJ,CAAC;KAAM,CAAC;IACP,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;QAC5C,UAAU,CAAC,KAAK,IAAI,EAAE;YACrB,MAAM,YAAY,CAAC,MAAM,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,8BAA8B,CAAC;YAC9B,IAAI,EAAE,6BAA6B;YACnC,WAAW,EAAE,KAAK,IAAI,EAAE;gBACvB,MAAM,KAAK,GAAG,IAAI,2BAA2B,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;gBAC7D,MAAM,QAAQ,GAAG,KAAK,EAAE,KAAa,EAAqB,EAAE;oBAC3D,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC;wBACtE,KAAK;wBACL,QAAQ,EAAE,gCAAgC;wBAC1C,aAAa,EAAE,IAAI;qBACnB,CAAC,CAAC;oBACH,IAAI,KAAK;wBAAE,MAAM,KAAK,CAAC;oBACvB,OAAO;wBACN,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE;wBAChB,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK;qBACtB,CAAC;gBACH,CAAC,CAAC;gBACF,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;YAC5B,CAAC;SACD,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -1,600 +0,0 @@
|
|
|
1
|
-
import { createClient, type SupabaseClient, type User } from '@supabase/supabase-js';
|
|
2
|
-
import type {
|
|
3
|
-
IAuthProvider,
|
|
4
|
-
IEmailLinkAuth,
|
|
5
|
-
IOAuthAuth,
|
|
6
|
-
IPasswordAuth,
|
|
7
|
-
AuthUser,
|
|
8
|
-
LoginResult,
|
|
9
|
-
UserManagementResult,
|
|
10
|
-
ListOptions,
|
|
11
|
-
Page
|
|
12
|
-
} from '@selvajs/platform';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Auth backed by Supabase Auth (GoTrue).
|
|
16
|
-
*
|
|
17
|
-
* Identity + session lifecycle are fully delegated:
|
|
18
|
-
* - `verifyLogin` wraps `auth.signInWithPassword` — returns the access_token
|
|
19
|
-
* straight from Supabase as the session token.
|
|
20
|
-
* - `verifyToken` calls `auth.getUser(token)` against an anon client scoped
|
|
21
|
-
* to that bearer; GoTrue validates the JWT for us.
|
|
22
|
-
* - User management uses `auth.admin.*` with the service-role client.
|
|
23
|
-
*
|
|
24
|
-
* Identity-only — platform permissions live on `IPlatformPermissionStore`
|
|
25
|
-
* (`SupabasePlatformPermissionStore` reads `user_profiles.platform_permissions`)
|
|
26
|
-
* and profile state on `IUserProfileStore`.
|
|
27
|
-
*/
|
|
28
|
-
export interface SupabaseAuthProviderConfig {
|
|
29
|
-
supabaseUrl: string;
|
|
30
|
-
anonKey: string;
|
|
31
|
-
serviceRoleKey: string;
|
|
32
|
-
/**
|
|
33
|
-
* Whether self-service signup is allowed via `passwordAuth.registerUser`.
|
|
34
|
-
* Default false — production deployments start with invite-only.
|
|
35
|
-
*/
|
|
36
|
-
enableSelfSignup?: boolean;
|
|
37
|
-
/**
|
|
38
|
-
* OAuth providers enabled in the Supabase dashboard (lowercased:
|
|
39
|
-
* "google", "github", …). Surfaced via `oauth.listProviders()` so the
|
|
40
|
-
* driving layer doesn't read provider-specific env vars itself. Defaults
|
|
41
|
-
* to an empty list.
|
|
42
|
-
*/
|
|
43
|
-
oauthProviders?: readonly string[];
|
|
44
|
-
/**
|
|
45
|
-
* Allow `emailLink.sendMagicLink` to create new users on first request.
|
|
46
|
-
* Default true — matches Supabase's `signInWithOtp` default and gives
|
|
47
|
-
* fresh installs a working signup path. Set false for invite-only
|
|
48
|
-
* deployments; `sendMagicLink` for an unknown email returns
|
|
49
|
-
* `{ ok: false, reason: 'signup_disabled' }`.
|
|
50
|
-
*/
|
|
51
|
-
allowEmailLinkSignup?: boolean;
|
|
52
|
-
/**
|
|
53
|
-
* How `verifyToken` validates session JWTs (see the method for the full
|
|
54
|
-
* rationale):
|
|
55
|
-
* - `'hybrid'` (default): verify the JWT LOCALLY via `getClaims()` on every
|
|
56
|
-
* request (no network call when the project uses asymmetric signing keys —
|
|
57
|
-
* the modern default), and additionally re-check against GoTrue at most
|
|
58
|
-
* once per `revalidateMs` per session to catch server-side sign-outs and
|
|
59
|
-
* `disabled` flips. Bounds revocation latency to `revalidateMs`.
|
|
60
|
-
* - `'strict'`: call `getUser()` on every request (the pre-1b behavior).
|
|
61
|
-
* Instant revocation, one network round-trip per request. For operators
|
|
62
|
-
* who don't accept any revocation lag.
|
|
63
|
-
*/
|
|
64
|
-
tokenVerification?: 'hybrid' | 'strict';
|
|
65
|
-
/**
|
|
66
|
-
* Recheck window for `'hybrid'` verification, in ms. A verified session is
|
|
67
|
-
* re-validated against GoTrue at most once per this interval. Default 60s.
|
|
68
|
-
*/
|
|
69
|
-
revalidateMs?: number;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Default hybrid recheck window — see `revalidateMs`. */
|
|
73
|
-
const DEFAULT_REVALIDATE_MS = 60_000;
|
|
74
|
-
|
|
75
|
-
/** Parse a positive integer env var, or undefined if unset/invalid. */
|
|
76
|
-
function parsePositiveInt(raw: string | undefined): number | undefined {
|
|
77
|
-
if (!raw) return undefined;
|
|
78
|
-
const n = Number.parseInt(raw, 10);
|
|
79
|
-
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export class SupabaseAuthProvider implements IAuthProvider {
|
|
83
|
-
readonly name = 'Supabase Auth';
|
|
84
|
-
readonly passwordAuth: IPasswordAuth;
|
|
85
|
-
readonly oauth: IOAuthAuth;
|
|
86
|
-
readonly emailLink: IEmailLinkAuth;
|
|
87
|
-
|
|
88
|
-
private readonly admin: SupabaseClient;
|
|
89
|
-
private readonly anon: SupabaseClient;
|
|
90
|
-
private readonly anonKey: string;
|
|
91
|
-
private readonly supabaseUrl: string;
|
|
92
|
-
private readonly tokenVerification: 'hybrid' | 'strict';
|
|
93
|
-
private readonly revalidateMs: number;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Last time a given session was re-validated against GoTrue (epoch ms),
|
|
97
|
-
* keyed by the JWT `session_id`. Bounds the hybrid recheck to once per
|
|
98
|
-
* `revalidateMs` per session. Swept lazily (see `verifyToken`) so a burst of
|
|
99
|
-
* distinct sessions can't leak memory. Per-process; multi-instance drift is
|
|
100
|
-
* harmless (each instance rechecks on its own schedule).
|
|
101
|
-
*/
|
|
102
|
-
private readonly lastRevalidatedAt = new Map<string, number>();
|
|
103
|
-
|
|
104
|
-
constructor(config: SupabaseAuthProviderConfig) {
|
|
105
|
-
this.supabaseUrl = config.supabaseUrl;
|
|
106
|
-
this.anonKey = config.anonKey;
|
|
107
|
-
this.tokenVerification = config.tokenVerification ?? 'hybrid';
|
|
108
|
-
this.revalidateMs = config.revalidateMs ?? DEFAULT_REVALIDATE_MS;
|
|
109
|
-
this.admin = createClient(config.supabaseUrl, config.serviceRoleKey, {
|
|
110
|
-
auth: { persistSession: false, autoRefreshToken: false }
|
|
111
|
-
});
|
|
112
|
-
this.anon = createClient(config.supabaseUrl, config.anonKey, {
|
|
113
|
-
auth: { persistSession: false, autoRefreshToken: false }
|
|
114
|
-
});
|
|
115
|
-
this.passwordAuth = new SupabasePasswordAuth(
|
|
116
|
-
this.admin,
|
|
117
|
-
this.anon,
|
|
118
|
-
(email, password) => this.signIn(email, password),
|
|
119
|
-
config.enableSelfSignup ?? false,
|
|
120
|
-
(user) => this.hydrate(user)
|
|
121
|
-
);
|
|
122
|
-
this.oauth = new SupabaseOAuthAuth(
|
|
123
|
-
this.anon,
|
|
124
|
-
(user) => this.hydrate(user),
|
|
125
|
-
config.oauthProviders ?? []
|
|
126
|
-
);
|
|
127
|
-
this.emailLink = new SupabaseEmailLinkAuth(
|
|
128
|
-
this.anon,
|
|
129
|
-
(user) => this.hydrate(user),
|
|
130
|
-
config.allowEmailLinkSignup ?? true
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
static fromEnv(env: Record<string, string | undefined>): SupabaseAuthProvider {
|
|
135
|
-
const supabaseUrl = env.SUPABASE_URL;
|
|
136
|
-
const anonKey = env.SUPABASE_ANON_KEY;
|
|
137
|
-
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
138
|
-
if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
|
|
139
|
-
if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
|
|
140
|
-
if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
|
|
141
|
-
const oauthProviders = (env.SUPABASE_OAUTH_PROVIDERS ?? '')
|
|
142
|
-
.split(',')
|
|
143
|
-
.map((p) => p.trim().toLowerCase())
|
|
144
|
-
.filter((p) => p.length > 0);
|
|
145
|
-
// Local JWT verification is the default (fast; requires asymmetric signing
|
|
146
|
-
// keys to skip the network — the Supabase default since 2025). Set
|
|
147
|
-
// SUPABASE_TOKEN_VERIFICATION=strict to force a getUser() per request.
|
|
148
|
-
const tokenVerification = env.SUPABASE_TOKEN_VERIFICATION === 'strict' ? 'strict' : 'hybrid';
|
|
149
|
-
const revalidateMs = parsePositiveInt(env.SUPABASE_REVALIDATE_MS);
|
|
150
|
-
return new SupabaseAuthProvider({
|
|
151
|
-
supabaseUrl,
|
|
152
|
-
anonKey,
|
|
153
|
-
serviceRoleKey,
|
|
154
|
-
enableSelfSignup: env.SUPABASE_ENABLE_SELF_SIGNUP === 'true',
|
|
155
|
-
oauthProviders,
|
|
156
|
-
// Default true; set SUPABASE_ALLOW_EMAIL_LINK_SIGNUP=false to lock down
|
|
157
|
-
// to invite-only and reject magic-link signups for new addresses.
|
|
158
|
-
allowEmailLinkSignup: env.SUPABASE_ALLOW_EMAIL_LINK_SIGNUP !== 'false',
|
|
159
|
-
tokenVerification,
|
|
160
|
-
...(revalidateMs !== undefined ? { revalidateMs } : {})
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Verify a session JWT and return the user, or null if invalid/disabled.
|
|
166
|
-
* Runs on EVERY authenticated request, so the fast path avoids a network
|
|
167
|
-
* round-trip.
|
|
168
|
-
*
|
|
169
|
-
* `'hybrid'` (default):
|
|
170
|
-
* 1. `getClaims(token)` verifies the JWT. With asymmetric signing keys (the
|
|
171
|
-
* modern Supabase default) this is LOCAL — the SDK verifies against the
|
|
172
|
-
* cached JWKS via Web Crypto, no call to GoTrue. With a legacy symmetric
|
|
173
|
-
* HS256 secret the SDK transparently falls back to a network verify, so
|
|
174
|
-
* this is correct for both project types without us detecting the scheme
|
|
175
|
-
* or hand-rolling JWKS handling (which the Supabase docs warn against —
|
|
176
|
-
* it breaks under key rotation).
|
|
177
|
-
* 2. Local verification can't see a server-side sign-out or a `disabled`
|
|
178
|
-
* flip that happened AFTER the token was issued (the claims are a
|
|
179
|
-
* snapshot). So we additionally re-check against GoTrue via `getUser`,
|
|
180
|
-
* but at most once per `revalidateMs` per session — bounding revocation
|
|
181
|
-
* latency to that window while eliminating ~all per-request network
|
|
182
|
-
* calls under steady load.
|
|
183
|
-
*
|
|
184
|
-
* `'strict'`: `getUser` on every request — instant revocation, one round
|
|
185
|
-
* trip per request. The pre-1b behavior, for operators who accept no lag.
|
|
186
|
-
*/
|
|
187
|
-
async verifyToken(token: string): Promise<AuthUser | null> {
|
|
188
|
-
if (!token) return null;
|
|
189
|
-
|
|
190
|
-
if (this.tokenVerification === 'strict') {
|
|
191
|
-
return this.verifyViaGetUser(token);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// --- hybrid: local verify first ---
|
|
195
|
-
const claims = await this.getClaimsOrNull(token);
|
|
196
|
-
if (!claims) return null;
|
|
197
|
-
// `disabled` as of token issue. A flip AFTER issue is caught by the
|
|
198
|
-
// periodic recheck below.
|
|
199
|
-
if ((claims.user_metadata as { disabled?: boolean } | undefined)?.disabled === true) {
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Periodic recheck: catch sign-out / disabled since the token was issued.
|
|
204
|
-
const sessionId = typeof claims.session_id === 'string' ? claims.session_id : claims.sub;
|
|
205
|
-
if (this.shouldRevalidate(sessionId)) {
|
|
206
|
-
const fresh = await this.verifyViaGetUser(token);
|
|
207
|
-
// getUser rejected it (signed out, disabled, revoked) → deny now.
|
|
208
|
-
if (!fresh) {
|
|
209
|
-
this.lastRevalidatedAt.delete(sessionId);
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
this.markRevalidated(sessionId);
|
|
213
|
-
return fresh;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Build the user from claims — no network call on this path.
|
|
217
|
-
return {
|
|
218
|
-
id: claims.sub,
|
|
219
|
-
email: typeof claims.email === 'string' ? claims.email : undefined,
|
|
220
|
-
disabled: false,
|
|
221
|
-
metadata: claims.user_metadata
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Full network verify against GoTrue. Also the `'strict'`-mode path. */
|
|
226
|
-
private async verifyViaGetUser(token: string): Promise<AuthUser | null> {
|
|
227
|
-
const { data, error } = await this.anon.auth.getUser(token);
|
|
228
|
-
if (error || !data.user) return null;
|
|
229
|
-
if (data.user.user_metadata?.disabled === true) return null;
|
|
230
|
-
return this.hydrate(data.user);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Local JWT verification. Returns the claims, or null if invalid/expired. */
|
|
234
|
-
private async getClaimsOrNull(token: string) {
|
|
235
|
-
try {
|
|
236
|
-
const { data, error } = await this.anon.auth.getClaims(token);
|
|
237
|
-
if (error || !data) return null;
|
|
238
|
-
return data.claims;
|
|
239
|
-
} catch {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private shouldRevalidate(sessionId: string): boolean {
|
|
245
|
-
const last = this.lastRevalidatedAt.get(sessionId);
|
|
246
|
-
return last === undefined || Date.now() - last >= this.revalidateMs;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private markRevalidated(sessionId: string): void {
|
|
250
|
-
const now = Date.now();
|
|
251
|
-
this.lastRevalidatedAt.set(sessionId, now);
|
|
252
|
-
// Lazy sweep so a churn of distinct sessions can't grow the map without
|
|
253
|
-
// bound. Anything older than two windows is definitely stale (it would
|
|
254
|
-
// revalidate on next use anyway). Cheap: only runs when the map is large.
|
|
255
|
-
if (this.lastRevalidatedAt.size > 10_000) {
|
|
256
|
-
const cutoff = now - this.revalidateMs * 2;
|
|
257
|
-
for (const [id, ts] of this.lastRevalidatedAt) {
|
|
258
|
-
if (ts < cutoff) this.lastRevalidatedAt.delete(id);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async getUser(id: string): Promise<AuthUser | null> {
|
|
264
|
-
const { data, error } = await this.admin.auth.admin.getUserById(id);
|
|
265
|
-
if (error || !data.user) return null;
|
|
266
|
-
return this.hydrate(data.user);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async listUsers(opts?: ListOptions): Promise<Page<AuthUser> | null> {
|
|
270
|
-
// GoTrue's admin.listUsers uses its own pagination (page/perPage).
|
|
271
|
-
// Translate our cursor (offset) into page number.
|
|
272
|
-
const perPage = opts?.limit ?? 50;
|
|
273
|
-
const page = opts?.cursor ? parseInt(opts.cursor, 10) : 1;
|
|
274
|
-
const { data, error } = await this.admin.auth.admin.listUsers({
|
|
275
|
-
page: Number.isFinite(page) && page >= 1 ? page : 1,
|
|
276
|
-
perPage
|
|
277
|
-
});
|
|
278
|
-
if (error) throw error;
|
|
279
|
-
|
|
280
|
-
const users = data.users;
|
|
281
|
-
const hydrated = users.map((u) => this.hydrate(u));
|
|
282
|
-
// If the page was full, assume there may be more.
|
|
283
|
-
const nextCursor = users.length >= perPage ? String(page + 1) : undefined;
|
|
284
|
-
return { items: hydrated, nextCursor };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
async createUser(email: string): Promise<AuthUser> {
|
|
288
|
-
// Allowlist variant: create without a password. The user receives a
|
|
289
|
-
// magic-link / OIDC handoff from the consuming app. Identity-only;
|
|
290
|
-
// platform permissions are granted via IPlatformPermissionStore.
|
|
291
|
-
const { data, error } = await this.admin.auth.admin.createUser({
|
|
292
|
-
email,
|
|
293
|
-
email_confirm: true
|
|
294
|
-
});
|
|
295
|
-
if (error) throw error;
|
|
296
|
-
if (!data.user) throw new Error('createUser returned no user');
|
|
297
|
-
return this.hydrate(data.user);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
async deleteUser(id: string): Promise<UserManagementResult> {
|
|
301
|
-
// Identity-only delete. The §2 sole-`instance_admin` invariant lives
|
|
302
|
-
// on IPlatformPermissionStore; callers consult it before calling here.
|
|
303
|
-
const { error } = await this.admin.auth.admin.deleteUser(id);
|
|
304
|
-
if (error) {
|
|
305
|
-
// 404-shaped errors from GoTrue surface as { status: 404 } or a
|
|
306
|
-
// specific message. Normalize to 'not_found' rather than throwing.
|
|
307
|
-
const e = error as unknown as { status?: number; message?: string };
|
|
308
|
-
if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
|
|
309
|
-
throw error;
|
|
310
|
-
}
|
|
311
|
-
return 'ok';
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
async disableUser(id: string): Promise<UserManagementResult> {
|
|
315
|
-
const { data: existing, error: fetchError } = await this.admin.auth.admin.getUserById(id);
|
|
316
|
-
if (fetchError || !existing.user) return 'not_found';
|
|
317
|
-
// GoTrue's user_metadata update merges shallowly — preserve existing
|
|
318
|
-
// fields by spreading them in.
|
|
319
|
-
const mergedMetadata = {
|
|
320
|
-
...(existing.user.user_metadata ?? {}),
|
|
321
|
-
disabled: true
|
|
322
|
-
};
|
|
323
|
-
const { error } = await this.admin.auth.admin.updateUserById(id, {
|
|
324
|
-
user_metadata: mergedMetadata
|
|
325
|
-
});
|
|
326
|
-
if (error) {
|
|
327
|
-
const e = error as unknown as { status?: number; message?: string };
|
|
328
|
-
if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
|
|
329
|
-
throw error;
|
|
330
|
-
}
|
|
331
|
-
return 'ok';
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async touchLastLogin(id: string): Promise<void> {
|
|
335
|
-
// 60-second debounce. Matches the local provider.
|
|
336
|
-
const { data, error } = await this.admin
|
|
337
|
-
.from('user_profiles')
|
|
338
|
-
.select('last_login_at')
|
|
339
|
-
.eq('user_id', id)
|
|
340
|
-
.maybeSingle();
|
|
341
|
-
if (error) return;
|
|
342
|
-
|
|
343
|
-
if (data?.last_login_at) {
|
|
344
|
-
const prev = Date.parse(data.last_login_at);
|
|
345
|
-
if (Number.isFinite(prev) && Date.now() - prev < 60_000) return;
|
|
346
|
-
}
|
|
347
|
-
await this.admin
|
|
348
|
-
.from('user_profiles')
|
|
349
|
-
.update({ last_login_at: new Date().toISOString() })
|
|
350
|
-
.eq('user_id', id);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// OAuth lives on `this.oauth` (typed `IOAuthAuth`); see `SupabaseOAuthAuth`
|
|
354
|
-
// below. Mirrors the `passwordAuth` capability split.
|
|
355
|
-
|
|
356
|
-
// ============================================================================
|
|
357
|
-
// Internals
|
|
358
|
-
// ============================================================================
|
|
359
|
-
/**
|
|
360
|
-
* Sign in via GoTrue. Returns the access token string and the hydrated
|
|
361
|
-
* user. Called by `SupabasePasswordAuth.verifyLogin` — kept on the
|
|
362
|
-
* provider class so it has access to the hydrate helper without plumbing.
|
|
363
|
-
*/
|
|
364
|
-
private async signIn(
|
|
365
|
-
email: string,
|
|
366
|
-
password: string
|
|
367
|
-
): Promise<{ user: AuthUser; sessionToken: string } | null> {
|
|
368
|
-
const { data, error } = await this.anon.auth.signInWithPassword({ email, password });
|
|
369
|
-
if (error || !data.user || !data.session) return null;
|
|
370
|
-
if (data.user.user_metadata?.disabled === true) return null;
|
|
371
|
-
return { user: this.hydrate(data.user), sessionToken: data.session.access_token };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Map a GoTrue `User` to our identity-only `AuthUser`. Platform permissions
|
|
376
|
-
* live on `IPlatformPermissionStore` and profile fields (displayName,
|
|
377
|
-
* starred, recentRuns) on `IUserProfileStore` — neither is in AuthUser.
|
|
378
|
-
*/
|
|
379
|
-
private hydrate(user: User): AuthUser {
|
|
380
|
-
return {
|
|
381
|
-
id: user.id,
|
|
382
|
-
email: user.email ?? undefined,
|
|
383
|
-
createdAt: user.created_at ?? undefined,
|
|
384
|
-
lastLoginAt: user.last_sign_in_at ?? undefined,
|
|
385
|
-
disabled: user.user_metadata?.disabled === true,
|
|
386
|
-
metadata: user.user_metadata
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Expose the anon URL + key so other code (e.g. the same process's
|
|
392
|
-
* storage provider or per-request client factories) can build user-scoped
|
|
393
|
-
* clients without requiring a second config read.
|
|
394
|
-
*/
|
|
395
|
-
getAnonClientConfig(): { supabaseUrl: string; anonKey: string } {
|
|
396
|
-
return { supabaseUrl: this.supabaseUrl, anonKey: this.anonKey };
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
class SupabasePasswordAuth implements IPasswordAuth {
|
|
401
|
-
constructor(
|
|
402
|
-
private readonly admin: SupabaseClient,
|
|
403
|
-
private readonly anon: SupabaseClient,
|
|
404
|
-
private readonly signIn: (
|
|
405
|
-
email: string,
|
|
406
|
-
password: string
|
|
407
|
-
) => Promise<{ user: AuthUser; sessionToken: string } | null>,
|
|
408
|
-
private readonly enableSelfSignup: boolean,
|
|
409
|
-
private readonly hydrate: (user: User) => AuthUser
|
|
410
|
-
) {}
|
|
411
|
-
|
|
412
|
-
async verifyLogin(email: string, password: string): Promise<LoginResult> {
|
|
413
|
-
const result = await this.signIn(email, password);
|
|
414
|
-
if (!result) return { kind: 'failed', reason: 'invalid_credentials' };
|
|
415
|
-
return { kind: 'success', user: result.user, sessionToken: result.sessionToken };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async createUserWithPassword(email: string, password: string): Promise<AuthUser> {
|
|
419
|
-
// Identity-only — platform permissions are granted separately via
|
|
420
|
-
// IPlatformPermissionStore.set after creation.
|
|
421
|
-
const { data, error } = await this.admin.auth.admin.createUser({
|
|
422
|
-
email,
|
|
423
|
-
password,
|
|
424
|
-
email_confirm: true
|
|
425
|
-
});
|
|
426
|
-
if (error) throw error;
|
|
427
|
-
if (!data.user) throw new Error('createUserWithPassword returned no user');
|
|
428
|
-
return this.hydrate(data.user);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
async registerUser(email: string, password: string): Promise<AuthUser | null> {
|
|
432
|
-
if (!this.enableSelfSignup) return null;
|
|
433
|
-
const { data, error } = await this.anon.auth.signUp({ email, password });
|
|
434
|
-
if (error || !data.user) return null;
|
|
435
|
-
return this.hydrate(data.user);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Supabase-backed `IOAuthAuth`. Thin wrappers over GoTrue's `signInWithOAuth`
|
|
441
|
-
* / `exchangeCodeForSession` / `refreshSession` — the failure-shape mapping
|
|
442
|
-
* (return null vs throw) is the only logic.
|
|
443
|
-
*/
|
|
444
|
-
class SupabaseOAuthAuth implements IOAuthAuth {
|
|
445
|
-
constructor(
|
|
446
|
-
private readonly anon: SupabaseClient,
|
|
447
|
-
private readonly hydrate: (user: User) => AuthUser,
|
|
448
|
-
private readonly providers: readonly string[]
|
|
449
|
-
) {}
|
|
450
|
-
|
|
451
|
-
listProviders(): readonly string[] {
|
|
452
|
-
return this.providers;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async getOAuthAuthorizationUrl(
|
|
456
|
-
provider: 'google' | 'github' | 'azure' | 'gitlab',
|
|
457
|
-
redirectTo: string
|
|
458
|
-
): Promise<string> {
|
|
459
|
-
const { data, error } = await this.anon.auth.signInWithOAuth({
|
|
460
|
-
provider,
|
|
461
|
-
options: { redirectTo, skipBrowserRedirect: true }
|
|
462
|
-
});
|
|
463
|
-
if (error || !data.url) throw error ?? new Error('signInWithOAuth returned no URL');
|
|
464
|
-
return data.url;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
async exchangeOAuthCode(code: string): Promise<{
|
|
468
|
-
user: AuthUser;
|
|
469
|
-
sessionToken: string;
|
|
470
|
-
refreshToken: string;
|
|
471
|
-
} | null> {
|
|
472
|
-
const { data, error } = await this.anon.auth.exchangeCodeForSession(code);
|
|
473
|
-
if (error || !data.user || !data.session) return null;
|
|
474
|
-
if (data.user.user_metadata?.disabled === true) return null;
|
|
475
|
-
return {
|
|
476
|
-
user: this.hydrate(data.user),
|
|
477
|
-
sessionToken: data.session.access_token,
|
|
478
|
-
refreshToken: data.session.refresh_token
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
async refreshSession(refreshToken: string): Promise<{
|
|
483
|
-
sessionToken: string;
|
|
484
|
-
refreshToken: string;
|
|
485
|
-
} | null> {
|
|
486
|
-
const { data, error } = await this.anon.auth.refreshSession({ refresh_token: refreshToken });
|
|
487
|
-
if (error || !data.session) return null;
|
|
488
|
-
return {
|
|
489
|
-
sessionToken: data.session.access_token,
|
|
490
|
-
refreshToken: data.session.refresh_token
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Supabase-backed `IEmailLinkAuth`. Wraps GoTrue's `signInWithOtp` (send the
|
|
497
|
-
* link) and `verifyOtp` with `type: 'magiclink' | 'email' | 'signup'`
|
|
498
|
-
* (verify on click).
|
|
499
|
-
*
|
|
500
|
-
* Token shape: Supabase emails the user `{callbackUrl}?token_hash=…&type=…`.
|
|
501
|
-
* `verifyMagicLink` accepts either the full URL or just the raw `token_hash`
|
|
502
|
-
* — adapters MAY support either to give the route layer flexibility. The
|
|
503
|
-
* `type` query param decides which OTP variant `verifyOtp` runs (signup vs
|
|
504
|
-
* magic-link returning user vs invite acceptance), so we pass it through.
|
|
505
|
-
*/
|
|
506
|
-
class SupabaseEmailLinkAuth implements IEmailLinkAuth {
|
|
507
|
-
constructor(
|
|
508
|
-
private readonly anon: SupabaseClient,
|
|
509
|
-
private readonly hydrate: (user: User) => AuthUser,
|
|
510
|
-
private readonly allowSignup: boolean
|
|
511
|
-
) {}
|
|
512
|
-
|
|
513
|
-
async sendMagicLink(
|
|
514
|
-
email: string,
|
|
515
|
-
callbackUrl: string
|
|
516
|
-
): Promise<
|
|
517
|
-
{ ok: true } | { ok: false; reason: 'rate_limited' | 'signup_disabled' | 'invalid_email' }
|
|
518
|
-
> {
|
|
519
|
-
const { error } = await this.anon.auth.signInWithOtp({
|
|
520
|
-
email,
|
|
521
|
-
options: {
|
|
522
|
-
emailRedirectTo: callbackUrl,
|
|
523
|
-
shouldCreateUser: this.allowSignup
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
if (!error) return { ok: true };
|
|
527
|
-
|
|
528
|
-
// Map GoTrue's classified errors to our coarse reasons. Anything we
|
|
529
|
-
// don't recognize we throw — the route returns 500 and we get a stack
|
|
530
|
-
// trace, instead of swallowing a real bug as "rate limited".
|
|
531
|
-
const status = (error as { status?: number }).status;
|
|
532
|
-
const code = (error as { code?: string }).code;
|
|
533
|
-
const message = error.message ?? '';
|
|
534
|
-
if (status === 429 || /rate.?limit/i.test(message))
|
|
535
|
-
return { ok: false, reason: 'rate_limited' };
|
|
536
|
-
if (
|
|
537
|
-
code === 'otp_disabled' ||
|
|
538
|
-
code === 'signup_disabled' ||
|
|
539
|
-
/signup.*disabled/i.test(message)
|
|
540
|
-
) {
|
|
541
|
-
return { ok: false, reason: 'signup_disabled' };
|
|
542
|
-
}
|
|
543
|
-
if (code === 'validation_failed' || /invalid.*email|email.*invalid/i.test(message)) {
|
|
544
|
-
return { ok: false, reason: 'invalid_email' };
|
|
545
|
-
}
|
|
546
|
-
throw error;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async verifyMagicLink(rawCallbackUrl: string): Promise<{
|
|
550
|
-
user: AuthUser;
|
|
551
|
-
sessionToken: string;
|
|
552
|
-
refreshToken?: string;
|
|
553
|
-
} | null> {
|
|
554
|
-
const params = parseCallbackParams(rawCallbackUrl);
|
|
555
|
-
if (!params) return null;
|
|
556
|
-
const { tokenHash, type } = params;
|
|
557
|
-
|
|
558
|
-
const { data, error } = await this.anon.auth.verifyOtp({
|
|
559
|
-
token_hash: tokenHash,
|
|
560
|
-
type
|
|
561
|
-
});
|
|
562
|
-
if (error || !data.user || !data.session) return null;
|
|
563
|
-
if (data.user.user_metadata?.disabled === true) return null;
|
|
564
|
-
return {
|
|
565
|
-
user: this.hydrate(data.user),
|
|
566
|
-
sessionToken: data.session.access_token,
|
|
567
|
-
refreshToken: data.session.refresh_token
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Pull `token_hash` + `type` from either a full callback URL or a bare
|
|
574
|
-
* `token_hash=…&type=…` query string. Tolerant of both shapes so the route
|
|
575
|
-
* layer can pass whichever form is convenient.
|
|
576
|
-
*
|
|
577
|
-
* Returns null when the inputs aren't recognizable — the caller treats null
|
|
578
|
-
* as "send the user back to /login".
|
|
579
|
-
*/
|
|
580
|
-
function parseCallbackParams(
|
|
581
|
-
raw: string
|
|
582
|
-
): { tokenHash: string; type: 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery' } | null {
|
|
583
|
-
let search: URLSearchParams;
|
|
584
|
-
try {
|
|
585
|
-
// Full URL path
|
|
586
|
-
search = new URL(raw).searchParams;
|
|
587
|
-
} catch {
|
|
588
|
-
// Query-string-only path — treat as bare params.
|
|
589
|
-
search = new URLSearchParams(raw.startsWith('?') ? raw.slice(1) : raw);
|
|
590
|
-
}
|
|
591
|
-
const tokenHash = search.get('token_hash');
|
|
592
|
-
const type = search.get('type');
|
|
593
|
-
if (!tokenHash) return null;
|
|
594
|
-
const allowed = new Set(['magiclink', 'email', 'signup', 'invite', 'recovery']);
|
|
595
|
-
if (!type || !allowed.has(type)) return null;
|
|
596
|
-
return {
|
|
597
|
-
tokenHash,
|
|
598
|
-
type: type as 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery'
|
|
599
|
-
};
|
|
600
|
-
}
|