@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13
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/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Permissions
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A permission is a `resource:action` string.
|
|
9
|
+
*
|
|
10
|
+
* Resources are typically collection names or system resources.
|
|
11
|
+
* Actions are the operations allowed on that resource.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const permission: Permission = 'todos:write'
|
|
16
|
+
* const adminPerm: Permission = 'org:manage-members'
|
|
17
|
+
* const wildcard: Permission = 'todos:*' // all actions on todos
|
|
18
|
+
* const superAdmin: Permission = '*:*' // all actions on all resources
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export type Permission = `${string}:${string}`
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a permission string into its resource and action parts.
|
|
25
|
+
*/
|
|
26
|
+
export function parsePermission(permission: Permission): { resource: string; action: string } {
|
|
27
|
+
const colonIndex = permission.indexOf(':')
|
|
28
|
+
if (colonIndex === -1) {
|
|
29
|
+
throw new InvalidPermissionError(permission)
|
|
30
|
+
}
|
|
31
|
+
const resource = permission.slice(0, colonIndex)
|
|
32
|
+
const action = permission.slice(colonIndex + 1)
|
|
33
|
+
if (resource.length === 0 || action.length === 0) {
|
|
34
|
+
throw new InvalidPermissionError(permission)
|
|
35
|
+
}
|
|
36
|
+
return { resource, action }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a granted permission covers a required permission.
|
|
41
|
+
* Supports wildcards: `todos:*` covers `todos:read`, `*:*` covers everything.
|
|
42
|
+
*/
|
|
43
|
+
export function permissionCovers(granted: Permission, required: Permission): boolean {
|
|
44
|
+
const g = parsePermission(granted)
|
|
45
|
+
const r = parsePermission(required)
|
|
46
|
+
|
|
47
|
+
const resourceMatch = g.resource === '*' || g.resource === r.resource
|
|
48
|
+
const actionMatch = g.action === '*' || g.action === r.action
|
|
49
|
+
return resourceMatch && actionMatch
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Role Definitions
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A role definition describes a named set of permissions with optional inheritance.
|
|
58
|
+
*
|
|
59
|
+
* Roles form a hierarchy via `inherits`. When evaluating permissions,
|
|
60
|
+
* all inherited roles' permissions are included transitively.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* const roles: RoleDefinition[] = [
|
|
65
|
+
* { name: 'viewer', permissions: ['todos:read', 'projects:read'] },
|
|
66
|
+
* { name: 'member', permissions: ['todos:write', 'projects:write'], inherits: ['viewer'] },
|
|
67
|
+
* { name: 'admin', permissions: ['org:manage-members'], inherits: ['member'] },
|
|
68
|
+
* ]
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export interface RoleDefinition {
|
|
72
|
+
/** Unique name for this role */
|
|
73
|
+
name: string
|
|
74
|
+
/** Permissions directly granted to this role */
|
|
75
|
+
permissions: Permission[]
|
|
76
|
+
/** Roles this role inherits from (all their permissions are included) */
|
|
77
|
+
inherits?: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Built-in Roles
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Default built-in roles for organizations.
|
|
86
|
+
*
|
|
87
|
+
* These can be overridden or extended using `defineRoles()`.
|
|
88
|
+
*/
|
|
89
|
+
export const BUILT_IN_ROLES: readonly RoleDefinition[] = [
|
|
90
|
+
{
|
|
91
|
+
name: 'viewer',
|
|
92
|
+
permissions: ['*:read'],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'billing',
|
|
96
|
+
permissions: ['org:billing'],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'member',
|
|
100
|
+
permissions: ['*:write', '*:delete'],
|
|
101
|
+
inherits: ['viewer'],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'admin',
|
|
105
|
+
permissions: ['org:manage-members', 'org:manage-settings', 'org:manage-invitations'],
|
|
106
|
+
inherits: ['member'],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'owner',
|
|
110
|
+
permissions: ['*:*'],
|
|
111
|
+
},
|
|
112
|
+
] as const
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// RBAC Configuration
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Configuration for the RBAC engine.
|
|
120
|
+
*/
|
|
121
|
+
export interface RbacConfig {
|
|
122
|
+
/** Role definitions. Defaults to BUILT_IN_ROLES if not provided. */
|
|
123
|
+
roles?: RoleDefinition[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// Scope Types
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A sync scope filter for a single collection.
|
|
132
|
+
* The keys are field names and values are the required values.
|
|
133
|
+
* A special `__readonly` key can be set to restrict to read-only access.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* // Only sync todos belonging to the user within the org
|
|
138
|
+
* const scope: ScopeFilter = { orgId: 'org-123', userId: 'user-456' }
|
|
139
|
+
*
|
|
140
|
+
* // Read-only scope for viewers
|
|
141
|
+
* const readOnlyScope: ScopeFilter = { orgId: 'org-123', __readonly: true }
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export interface ScopeFilter {
|
|
145
|
+
[field: string]: unknown
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A complete set of sync scopes, keyed by collection name.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* const scopes: SyncScopes = {
|
|
154
|
+
* todos: { orgId: 'org-123' },
|
|
155
|
+
* projects: { orgId: 'org-123' },
|
|
156
|
+
* }
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export type SyncScopes = Record<string, ScopeFilter>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Custom scope resolver for a specific collection.
|
|
163
|
+
* Given the user context, returns the scope filter for that collection.
|
|
164
|
+
*/
|
|
165
|
+
export type CollectionScopeResolver = (ctx: ScopeContext) => ScopeFilter | null
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Context passed to scope resolvers.
|
|
169
|
+
*/
|
|
170
|
+
export interface ScopeContext {
|
|
171
|
+
userId: string
|
|
172
|
+
orgId: string
|
|
173
|
+
role: string
|
|
174
|
+
permissions: Permission[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Errors
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
export class RbacError extends KoraError {
|
|
182
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
183
|
+
super(message, code, context)
|
|
184
|
+
this.name = 'RbacError'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class InvalidPermissionError extends RbacError {
|
|
189
|
+
constructor(permission: string) {
|
|
190
|
+
super(
|
|
191
|
+
`Invalid permission format: "${permission}". Expected "resource:action".`,
|
|
192
|
+
'INVALID_PERMISSION',
|
|
193
|
+
{ permission },
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class RoleNotFoundError extends RbacError {
|
|
199
|
+
constructor(role: string) {
|
|
200
|
+
super(`Role "${role}" is not defined.`, 'ROLE_NOT_FOUND', { role })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export class CircularInheritanceError extends RbacError {
|
|
205
|
+
constructor(chain: string[]) {
|
|
206
|
+
super(`Circular role inheritance detected: ${chain.join(' → ')}.`, 'CIRCULAR_INHERITANCE', {
|
|
207
|
+
chain,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { OrgStore } from '../org/org-store'
|
|
2
|
+
import type { OrgRole } from '../org/org-types'
|
|
3
|
+
import type { RbacEngine } from './rbac-engine'
|
|
4
|
+
import type {
|
|
5
|
+
CollectionScopeResolver,
|
|
6
|
+
Permission,
|
|
7
|
+
ScopeContext,
|
|
8
|
+
ScopeFilter,
|
|
9
|
+
SyncScopes,
|
|
10
|
+
} from './rbac-types'
|
|
11
|
+
import { permissionCovers } from './rbac-types'
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// OrgScopeResolver
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves sync scopes for org-aware data filtering.
|
|
19
|
+
*
|
|
20
|
+
* Given a user, an organization, and a set of collections, this resolver
|
|
21
|
+
* determines what data the user should receive during sync:
|
|
22
|
+
*
|
|
23
|
+
* - **Owner/Admin**: All data in the org (no field-level filtering)
|
|
24
|
+
* - **Member**: All org data with read/write access
|
|
25
|
+
* - **Viewer**: All org data with read-only flag
|
|
26
|
+
* - **Billing**: No data access (empty scopes)
|
|
27
|
+
*
|
|
28
|
+
* Developers can register per-collection scope resolvers for fine-grained control.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const resolver = new OrgScopeResolver(orgStore, rbacEngine)
|
|
33
|
+
*
|
|
34
|
+
* // Custom scope: members only see their own todos
|
|
35
|
+
* resolver.registerCollectionScope('todos', (ctx) => {
|
|
36
|
+
* if (ctx.role === 'member') {
|
|
37
|
+
* return { orgId: ctx.orgId, userId: ctx.userId }
|
|
38
|
+
* }
|
|
39
|
+
* return { orgId: ctx.orgId } // admins/owners see all
|
|
40
|
+
* })
|
|
41
|
+
*
|
|
42
|
+
* const scopes = await resolver.resolve('user-1', 'org-1', ['todos', 'projects'])
|
|
43
|
+
* // { todos: { orgId: 'org-1', userId: 'user-1' }, projects: { orgId: 'org-1' } }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export class OrgScopeResolver {
|
|
47
|
+
private readonly orgStore: OrgStore
|
|
48
|
+
private readonly rbac: RbacEngine
|
|
49
|
+
private readonly collectionScopes = new Map<string, CollectionScopeResolver>()
|
|
50
|
+
|
|
51
|
+
constructor(orgStore: OrgStore, rbac: RbacEngine) {
|
|
52
|
+
this.orgStore = orgStore
|
|
53
|
+
this.rbac = rbac
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a custom scope resolver for a collection.
|
|
58
|
+
* This overrides the default orgId-based filtering for that collection.
|
|
59
|
+
*/
|
|
60
|
+
registerCollectionScope(collection: string, resolver: CollectionScopeResolver): void {
|
|
61
|
+
this.collectionScopes.set(collection, resolver)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve sync scopes for all specified collections.
|
|
66
|
+
*
|
|
67
|
+
* Returns null if the user is not a member of the organization.
|
|
68
|
+
* Returns an empty object if the user has no data access (e.g., billing role).
|
|
69
|
+
*/
|
|
70
|
+
async resolve(userId: string, orgId: string, collections: string[]): Promise<SyncScopes | null> {
|
|
71
|
+
const membership = await this.orgStore.getMembership(orgId, userId)
|
|
72
|
+
if (!membership) return null
|
|
73
|
+
|
|
74
|
+
const permissions = this.rbac.getRolePermissions(membership.role)
|
|
75
|
+
const ctx: ScopeContext = {
|
|
76
|
+
userId,
|
|
77
|
+
orgId,
|
|
78
|
+
role: membership.role,
|
|
79
|
+
permissions,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const scopes: SyncScopes = {}
|
|
83
|
+
|
|
84
|
+
for (const collection of collections) {
|
|
85
|
+
const scope = this.resolveCollectionScope(ctx, collection)
|
|
86
|
+
if (scope) {
|
|
87
|
+
scopes[collection] = scope
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return scopes
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a user can write to a specific collection in an org.
|
|
96
|
+
*/
|
|
97
|
+
async canWrite(userId: string, orgId: string, collection: string): Promise<boolean> {
|
|
98
|
+
return this.rbac.hasPermission(userId, orgId, `${collection}:write` as Permission)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if a user can read from a specific collection in an org.
|
|
103
|
+
*/
|
|
104
|
+
async canRead(userId: string, orgId: string, collection: string): Promise<boolean> {
|
|
105
|
+
return this.rbac.hasPermission(userId, orgId, `${collection}:read` as Permission)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Private ---
|
|
109
|
+
|
|
110
|
+
private resolveCollectionScope(ctx: ScopeContext, collection: string): ScopeFilter | null {
|
|
111
|
+
// Check if user has read permission for this collection
|
|
112
|
+
const canRead = ctx.permissions.some(
|
|
113
|
+
(p) =>
|
|
114
|
+
permissionCovers(p, `${collection}:read` as Permission) ||
|
|
115
|
+
permissionCovers(p, '*:read' as Permission),
|
|
116
|
+
)
|
|
117
|
+
if (!canRead) return null
|
|
118
|
+
|
|
119
|
+
// Check custom resolver
|
|
120
|
+
const customResolver = this.collectionScopes.get(collection)
|
|
121
|
+
if (customResolver) {
|
|
122
|
+
return customResolver(ctx)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default scope: filter by orgId
|
|
126
|
+
const scope: ScopeFilter = { orgId: ctx.orgId }
|
|
127
|
+
|
|
128
|
+
// Check if write access is available
|
|
129
|
+
const canWrite = ctx.permissions.some(
|
|
130
|
+
(p) =>
|
|
131
|
+
permissionCovers(p, `${collection}:write` as Permission) ||
|
|
132
|
+
permissionCovers(p, '*:write' as Permission),
|
|
133
|
+
)
|
|
134
|
+
if (!canWrite) {
|
|
135
|
+
scope.__readonly = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return scope
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createElement, useEffect, useMemo, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
3
|
+
import { createAuthSession } from '../bindings/create-auth-session'
|
|
4
|
+
import type { AuthSession } from '../bindings/create-auth-session'
|
|
5
|
+
import type { AuthClient } from '../client/auth-client'
|
|
6
|
+
import { AuthContext } from './auth-context'
|
|
7
|
+
|
|
8
|
+
interface AuthProviderProps {
|
|
9
|
+
client: AuthClient
|
|
10
|
+
children: ReactNode
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type SessionEntry = {
|
|
15
|
+
session: AuthSession
|
|
16
|
+
refs: number
|
|
17
|
+
destroyTimer: ReturnType<typeof setTimeout> | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sessionEntries = new WeakMap<AuthClient, SessionEntry>()
|
|
21
|
+
|
|
22
|
+
function getSessionEntry(client: AuthClient): SessionEntry {
|
|
23
|
+
const existing = sessionEntries.get(client)
|
|
24
|
+
if (existing) return existing
|
|
25
|
+
|
|
26
|
+
const entry: SessionEntry = {
|
|
27
|
+
session: createAuthSession(client),
|
|
28
|
+
refs: 0,
|
|
29
|
+
destroyTimer: null,
|
|
30
|
+
}
|
|
31
|
+
sessionEntries.set(client, entry)
|
|
32
|
+
return entry
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function retainSession(entry: SessionEntry): () => void {
|
|
36
|
+
if (entry.destroyTimer) {
|
|
37
|
+
clearTimeout(entry.destroyTimer)
|
|
38
|
+
entry.destroyTimer = null
|
|
39
|
+
}
|
|
40
|
+
entry.refs++
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
entry.refs = Math.max(0, entry.refs - 1)
|
|
44
|
+
if (entry.refs > 0 || entry.destroyTimer) return
|
|
45
|
+
|
|
46
|
+
// React StrictMode intentionally runs effect cleanup and setup back-to-back
|
|
47
|
+
// on mount. Deferring disposal lets the second setup retain the same session,
|
|
48
|
+
// while a real unmount still releases the auth subscription shortly after.
|
|
49
|
+
entry.destroyTimer = setTimeout(() => {
|
|
50
|
+
entry.destroyTimer = null
|
|
51
|
+
if (entry.refs > 0) return
|
|
52
|
+
entry.session.destroy()
|
|
53
|
+
sessionEntries.delete(entry.session.client)
|
|
54
|
+
}, 0)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function AuthProvider({ client, children, fallback }: AuthProviderProps): ReactElement {
|
|
59
|
+
const entry = useMemo(() => getSessionEntry(client), [client])
|
|
60
|
+
const session = entry.session
|
|
61
|
+
|
|
62
|
+
useEffect(() => retainSession(entry), [entry])
|
|
63
|
+
|
|
64
|
+
const snapshot = useSyncExternalStore(
|
|
65
|
+
(onStoreChange) => session.subscribe(onStoreChange),
|
|
66
|
+
() => session.getSnapshot(),
|
|
67
|
+
() => session.getSnapshot(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if (snapshot.initError) {
|
|
71
|
+
return createElement(
|
|
72
|
+
'div',
|
|
73
|
+
{
|
|
74
|
+
style: { color: 'red', padding: '1rem', fontFamily: 'monospace' },
|
|
75
|
+
role: 'alert',
|
|
76
|
+
},
|
|
77
|
+
createElement('strong', null, 'Kora Auth initialization error: '),
|
|
78
|
+
snapshot.initError.message,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (snapshot.isLoading && fallback !== undefined) {
|
|
83
|
+
return fallback as ReactElement
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const contextValue = {
|
|
87
|
+
client,
|
|
88
|
+
session,
|
|
89
|
+
state: snapshot.state,
|
|
90
|
+
isLoading: snapshot.isLoading,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return createElement(AuthContext.Provider, { value: contextValue }, children)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { AuthProvider }
|
|
97
|
+
export type { AuthProviderProps }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { type OrgSession, createOrgSession } from '../bindings/create-org-session'
|
|
3
|
+
import type { OrgClient } from '../client/org-client'
|
|
4
|
+
import { OrgContext, type OrgContextValue } from './org-hooks'
|
|
5
|
+
|
|
6
|
+
export interface OrgProviderProps {
|
|
7
|
+
client: OrgClient
|
|
8
|
+
children?: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provides organization context for {@link useOrg}, {@link useOrgMembers}, and {@link usePermission}.
|
|
13
|
+
*/
|
|
14
|
+
export function OrgProvider({ client, children }: OrgProviderProps) {
|
|
15
|
+
const sessionRef = useRef<OrgSession | null>(null)
|
|
16
|
+
|
|
17
|
+
if (sessionRef.current === null || sessionRef.current.client !== client) {
|
|
18
|
+
sessionRef.current?.destroy()
|
|
19
|
+
sessionRef.current = createOrgSession(client)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
return () => {
|
|
24
|
+
sessionRef.current?.destroy()
|
|
25
|
+
sessionRef.current = null
|
|
26
|
+
}
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
const session = sessionRef.current
|
|
30
|
+
const value = useMemo<OrgContextValue>(
|
|
31
|
+
() => ({
|
|
32
|
+
client,
|
|
33
|
+
session,
|
|
34
|
+
}),
|
|
35
|
+
[client, session],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return <OrgContext.Provider value={value}>{children}</OrgContext.Provider>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type { OrgContextValue }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
import type { AuthSession } from '../bindings/create-auth-session'
|
|
3
|
+
import type { AuthClient } from '../client/auth-client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Possible authentication states for the client.
|
|
7
|
+
*/
|
|
8
|
+
type AuthState = 'loading' | 'authenticated' | 'unauthenticated'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shape of the value provided by the AuthContext.
|
|
12
|
+
*/
|
|
13
|
+
interface AuthContextValue {
|
|
14
|
+
client: AuthClient
|
|
15
|
+
session: AuthSession
|
|
16
|
+
state: AuthState
|
|
17
|
+
isLoading: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* React context for Kora authentication.
|
|
22
|
+
*/
|
|
23
|
+
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
24
|
+
|
|
25
|
+
export { AuthContext }
|
|
26
|
+
export type { AuthContextValue, AuthState }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useContext, useSyncExternalStore } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
AuthState,
|
|
4
|
+
AuthUser,
|
|
5
|
+
LinkedOAuthAccount,
|
|
6
|
+
OAuthAuthorizationOptions,
|
|
7
|
+
OAuthAuthorizationResult,
|
|
8
|
+
OAuthCallbackParams,
|
|
9
|
+
} from '../client/auth-client'
|
|
10
|
+
import { AuthContext } from './auth-context'
|
|
11
|
+
|
|
12
|
+
function useAuthContext() {
|
|
13
|
+
const ctx = useContext(AuthContext)
|
|
14
|
+
if (ctx === null) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'useAuth / useCurrentUser / useAuthStatus must be used within an <AuthProvider>. ' +
|
|
17
|
+
'Wrap your component tree with <AuthProvider client={authClient}>.',
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
return ctx
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UseAuthResult {
|
|
24
|
+
user: AuthUser | null
|
|
25
|
+
isAuthenticated: boolean
|
|
26
|
+
isLoading: boolean
|
|
27
|
+
signUp: (params: {
|
|
28
|
+
email: string
|
|
29
|
+
password: string
|
|
30
|
+
name?: string
|
|
31
|
+
deviceId?: string
|
|
32
|
+
devicePublicKey?: string
|
|
33
|
+
}) => Promise<void>
|
|
34
|
+
signIn: (params: {
|
|
35
|
+
email: string
|
|
36
|
+
password: string
|
|
37
|
+
deviceId?: string
|
|
38
|
+
devicePublicKey?: string
|
|
39
|
+
}) => Promise<void>
|
|
40
|
+
signInWithOAuth: (
|
|
41
|
+
provider: string,
|
|
42
|
+
options?: OAuthAuthorizationOptions,
|
|
43
|
+
) => Promise<OAuthAuthorizationResult>
|
|
44
|
+
completeOAuthSignIn: (provider: string, params: OAuthCallbackParams) => Promise<void>
|
|
45
|
+
getOAuthAuthorizationUrl: (
|
|
46
|
+
provider: string,
|
|
47
|
+
options?: OAuthAuthorizationOptions,
|
|
48
|
+
) => Promise<OAuthAuthorizationResult>
|
|
49
|
+
linkOAuth: (provider: string, params: OAuthCallbackParams) => Promise<LinkedOAuthAccount | null>
|
|
50
|
+
listLinkedAccounts: () => Promise<LinkedOAuthAccount[]>
|
|
51
|
+
unlinkOAuth: (provider: string) => Promise<void>
|
|
52
|
+
signOut: () => Promise<void>
|
|
53
|
+
error: string | null
|
|
54
|
+
initError: Error | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AuthStatus {
|
|
58
|
+
state: AuthState
|
|
59
|
+
isAuthenticated: boolean
|
|
60
|
+
isLoading: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function useAuthSessionSnapshot() {
|
|
64
|
+
const { session } = useAuthContext()
|
|
65
|
+
|
|
66
|
+
return useSyncExternalStore(
|
|
67
|
+
(onStoreChange) => session.subscribe(onStoreChange),
|
|
68
|
+
() => session.getSnapshot(),
|
|
69
|
+
() => session.getSnapshot(),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function useAuth(): UseAuthResult {
|
|
74
|
+
const { session } = useAuthContext()
|
|
75
|
+
const snapshot = useAuthSessionSnapshot()
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
user: snapshot.user,
|
|
79
|
+
isAuthenticated: snapshot.isAuthenticated,
|
|
80
|
+
isLoading: snapshot.isLoading,
|
|
81
|
+
error: snapshot.error,
|
|
82
|
+
initError: snapshot.initError,
|
|
83
|
+
signUp: (params) => session.signUp(params),
|
|
84
|
+
signIn: (params) => session.signIn(params),
|
|
85
|
+
signInWithOAuth: (provider, options) => session.signInWithOAuth(provider, options),
|
|
86
|
+
completeOAuthSignIn: (provider, params) => session.completeOAuthSignIn(provider, params),
|
|
87
|
+
getOAuthAuthorizationUrl: (provider, options) =>
|
|
88
|
+
session.getOAuthAuthorizationUrl(provider, options),
|
|
89
|
+
linkOAuth: (provider, params) => session.linkOAuth(provider, params),
|
|
90
|
+
listLinkedAccounts: () => session.listLinkedAccounts(),
|
|
91
|
+
unlinkOAuth: (provider) => session.unlinkOAuth(provider),
|
|
92
|
+
signOut: () => session.signOut(),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function useCurrentUser(): AuthUser | null {
|
|
97
|
+
return useAuthSessionSnapshot().user
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function useAuthStatus(): AuthStatus {
|
|
101
|
+
const snapshot = useAuthSessionSnapshot()
|
|
102
|
+
return {
|
|
103
|
+
state: snapshot.state,
|
|
104
|
+
isAuthenticated: snapshot.isAuthenticated,
|
|
105
|
+
isLoading: snapshot.isLoading,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { useAuth, useCurrentUser, useAuthStatus }
|
|
110
|
+
export type { UseAuthResult, AuthStatus }
|