@cloudauth/adapter-drizzle 0.1.0
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 +21 -0
- package/dist/adapter 2.js +181 -0
- package/dist/adapter.d 2.ts +7 -0
- package/dist/adapter.d.ts +7 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +181 -0
- package/dist/adapter.js 2.map +1 -0
- package/dist/adapter.js.map +1 -0
- package/dist/index 2.js +4 -0
- package/dist/index.d 2.ts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts 2.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/schema-pg.d 2.ts +624 -0
- package/dist/schema-pg.d.ts +624 -0
- package/dist/schema-pg.d.ts 2.map +1 -0
- package/dist/schema-pg.d.ts.map +1 -0
- package/dist/schema-pg.js +52 -0
- package/dist/schema-pg.js 2.map +1 -0
- package/dist/schema-pg.js.map +1 -0
- package/dist/schema.d 2.ts +672 -0
- package/dist/schema.d.ts +672 -0
- package/dist/schema.d.ts 2.map +1 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +52 -0
- package/dist/schema.js.map +1 -0
- package/dist/test/adapter.test 2.js +88 -0
- package/dist/test/adapter.test.d 2.ts +2 -0
- package/dist/test/adapter.test.d 3.ts +2 -0
- package/dist/test/adapter.test.d.ts +2 -0
- package/dist/test/adapter.test.d.ts 2.map +1 -0
- package/dist/test/adapter.test.d.ts 3.map +1 -0
- package/dist/test/adapter.test.d.ts.map +1 -0
- package/dist/test/adapter.test.js +88 -0
- package/dist/test/adapter.test.js 2.map +1 -0
- package/dist/test/adapter.test.js 3.map +1 -0
- package/dist/test/adapter.test.js.map +1 -0
- package/dist/test/auth.test.d 2.ts +2 -0
- package/dist/test/auth.test.d.ts +2 -0
- package/dist/test/auth.test.d.ts 2.map +1 -0
- package/dist/test/auth.test.d.ts.map +1 -0
- package/dist/test/auth.test.js +181 -0
- package/dist/test/auth.test.js 2.map +1 -0
- package/dist/test/auth.test.js.map +1 -0
- package/package.json +36 -0
- package/src/adapter.ts +227 -0
- package/src/index.ts +3 -0
- package/src/schema-pg.ts +62 -0
- package/src/schema.ts +62 -0
- package/src/test/adapter.test.ts +93 -0
- package/src/test/auth.test.ts +211 -0
- package/tsconfig 2.tsbuildinfo +1 -0
- package/tsconfig 3.tsbuildinfo +1 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +7 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { eq, sql, and } from 'drizzle-orm'
|
|
2
|
+
import type { AuthAdapter } from '@cloudauth/core'
|
|
3
|
+
import type {
|
|
4
|
+
CreateUserInput,
|
|
5
|
+
UpdateUserInput,
|
|
6
|
+
CreateAccountInput,
|
|
7
|
+
CreateSessionInput,
|
|
8
|
+
UpdateSessionInput,
|
|
9
|
+
CreateOAuthStateInput,
|
|
10
|
+
User,
|
|
11
|
+
Account,
|
|
12
|
+
Session,
|
|
13
|
+
OAuthState,
|
|
14
|
+
SessionWithUser,
|
|
15
|
+
} from '@cloudauth/core'
|
|
16
|
+
import * as schema from './schema.js'
|
|
17
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
|
18
|
+
|
|
19
|
+
type Database = LibSQLDatabase<typeof schema>
|
|
20
|
+
|
|
21
|
+
function now(): Date {
|
|
22
|
+
return new Date()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertRow<T>(row: T | undefined): T {
|
|
26
|
+
if (!row) {
|
|
27
|
+
throw new Error('Expected a row to be returned from the database')
|
|
28
|
+
}
|
|
29
|
+
return row
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function drizzleAdapter(db: Database): AuthAdapter {
|
|
33
|
+
const adapter: AuthAdapter = {
|
|
34
|
+
// --- User operations ---
|
|
35
|
+
|
|
36
|
+
async createUser(data: CreateUserInput): Promise<User> {
|
|
37
|
+
const [row] = await db
|
|
38
|
+
.insert(schema.users)
|
|
39
|
+
.values({
|
|
40
|
+
id: crypto.randomUUID(),
|
|
41
|
+
email: data.email,
|
|
42
|
+
emailVerified: data.emailVerified,
|
|
43
|
+
name: data.name,
|
|
44
|
+
image: data.image,
|
|
45
|
+
createdAt: now(),
|
|
46
|
+
updatedAt: now(),
|
|
47
|
+
})
|
|
48
|
+
.returning()
|
|
49
|
+
return assertRow(row)
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async getUserById(id: string): Promise<User | null> {
|
|
53
|
+
const [row] = await db.select().from(schema.users).where(eq(schema.users.id, id))
|
|
54
|
+
return row ?? null
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async getUserByEmail(email: string): Promise<User | null> {
|
|
58
|
+
if (!email) return null
|
|
59
|
+
const [row] = await db.select().from(schema.users).where(eq(schema.users.email, email))
|
|
60
|
+
return row ?? null
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async updateUser(id: string, data: UpdateUserInput): Promise<User> {
|
|
64
|
+
const [row] = await db
|
|
65
|
+
.update(schema.users)
|
|
66
|
+
.set({
|
|
67
|
+
...data,
|
|
68
|
+
updatedAt: now(),
|
|
69
|
+
})
|
|
70
|
+
.where(eq(schema.users.id, id))
|
|
71
|
+
.returning()
|
|
72
|
+
return assertRow(row)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// --- Account operations ---
|
|
76
|
+
|
|
77
|
+
async createAccount(data: CreateAccountInput): Promise<Account> {
|
|
78
|
+
const [row] = await db
|
|
79
|
+
.insert(schema.accounts)
|
|
80
|
+
.values({
|
|
81
|
+
id: crypto.randomUUID(),
|
|
82
|
+
userId: data.userId,
|
|
83
|
+
provider: data.provider,
|
|
84
|
+
providerAccountId: data.providerAccountId,
|
|
85
|
+
accessToken: data.accessToken ?? null,
|
|
86
|
+
refreshToken: data.refreshToken ?? null,
|
|
87
|
+
idToken: data.idToken ?? null,
|
|
88
|
+
scope: data.scope ?? null,
|
|
89
|
+
tokenType: data.tokenType ?? null,
|
|
90
|
+
expiresAt: data.expiresAt ?? null,
|
|
91
|
+
createdAt: now(),
|
|
92
|
+
updatedAt: now(),
|
|
93
|
+
})
|
|
94
|
+
.returning()
|
|
95
|
+
return assertRow(row)
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async getAccount(provider: string, providerAccountId: string): Promise<Account | null> {
|
|
99
|
+
const [row] = await db
|
|
100
|
+
.select()
|
|
101
|
+
.from(schema.accounts)
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(schema.accounts.provider, provider),
|
|
105
|
+
eq(schema.accounts.providerAccountId, providerAccountId),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
return row ?? null
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async getAccountsByUserId(userId: string): Promise<Account[]> {
|
|
112
|
+
return db.select().from(schema.accounts).where(eq(schema.accounts.userId, userId))
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async linkAccount(userId: string, data: CreateAccountInput): Promise<Account> {
|
|
116
|
+
const [row] = await db
|
|
117
|
+
.insert(schema.accounts)
|
|
118
|
+
.values({
|
|
119
|
+
id: crypto.randomUUID(),
|
|
120
|
+
userId,
|
|
121
|
+
provider: data.provider,
|
|
122
|
+
providerAccountId: data.providerAccountId,
|
|
123
|
+
accessToken: data.accessToken ?? null,
|
|
124
|
+
refreshToken: data.refreshToken ?? null,
|
|
125
|
+
idToken: data.idToken ?? null,
|
|
126
|
+
scope: data.scope ?? null,
|
|
127
|
+
tokenType: data.tokenType ?? null,
|
|
128
|
+
expiresAt: data.expiresAt ?? null,
|
|
129
|
+
createdAt: now(),
|
|
130
|
+
updatedAt: now(),
|
|
131
|
+
})
|
|
132
|
+
.returning()
|
|
133
|
+
return assertRow(row)
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async deleteAccount(accountId: string): Promise<void> {
|
|
137
|
+
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId))
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// --- Session operations ---
|
|
141
|
+
|
|
142
|
+
async createSession(data: CreateSessionInput): Promise<Session> {
|
|
143
|
+
const [row] = await db
|
|
144
|
+
.insert(schema.sessions)
|
|
145
|
+
.values({
|
|
146
|
+
id: crypto.randomUUID(),
|
|
147
|
+
userId: data.userId,
|
|
148
|
+
tokenHash: data.tokenHash,
|
|
149
|
+
expiresAt: data.expiresAt,
|
|
150
|
+
ipAddress: data.ipAddress ?? null,
|
|
151
|
+
userAgent: data.userAgent ?? null,
|
|
152
|
+
createdAt: now(),
|
|
153
|
+
updatedAt: now(),
|
|
154
|
+
})
|
|
155
|
+
.returning()
|
|
156
|
+
return assertRow(row)
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async getSessionByTokenHash(tokenHash: string): Promise<SessionWithUser | null> {
|
|
160
|
+
const [row] = await db
|
|
161
|
+
.select()
|
|
162
|
+
.from(schema.sessions)
|
|
163
|
+
.where(eq(schema.sessions.tokenHash, tokenHash))
|
|
164
|
+
.leftJoin(schema.users, eq(schema.sessions.userId, schema.users.id))
|
|
165
|
+
|
|
166
|
+
if (!row) return null
|
|
167
|
+
|
|
168
|
+
const user = row.users
|
|
169
|
+
if (!user) return null
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
session: row.sessions,
|
|
173
|
+
user,
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async updateSession(id: string, data: UpdateSessionInput): Promise<Session> {
|
|
178
|
+
const [row] = await db
|
|
179
|
+
.update(schema.sessions)
|
|
180
|
+
.set({
|
|
181
|
+
...data,
|
|
182
|
+
updatedAt: now(),
|
|
183
|
+
})
|
|
184
|
+
.where(eq(schema.sessions.id, id))
|
|
185
|
+
.returning()
|
|
186
|
+
return assertRow(row)
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async deleteSession(tokenHash: string): Promise<void> {
|
|
190
|
+
await db.delete(schema.sessions).where(eq(schema.sessions.tokenHash, tokenHash))
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async deleteExpiredSessions(): Promise<void> {
|
|
194
|
+
await db.delete(schema.sessions).where(sql`expires_at < ${Date.now()}`)
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// --- OAuth state operations ---
|
|
198
|
+
|
|
199
|
+
async createOAuthState(data: CreateOAuthStateInput): Promise<void> {
|
|
200
|
+
await db.insert(schema.oauthStates).values({
|
|
201
|
+
id: crypto.randomUUID(),
|
|
202
|
+
stateHash: data.stateHash,
|
|
203
|
+
codeVerifier: data.codeVerifier,
|
|
204
|
+
provider: data.provider,
|
|
205
|
+
callbackUrl: data.callbackUrl ?? null,
|
|
206
|
+
errorUrl: data.errorUrl ?? null,
|
|
207
|
+
expiresAt: data.expiresAt,
|
|
208
|
+
createdAt: now(),
|
|
209
|
+
})
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async consumeOAuthState(stateHash: string): Promise<OAuthState | null> {
|
|
213
|
+
const [state] = await db
|
|
214
|
+
.select()
|
|
215
|
+
.from(schema.oauthStates)
|
|
216
|
+
.where(eq(schema.oauthStates.stateHash, stateHash))
|
|
217
|
+
|
|
218
|
+
if (!state) return null
|
|
219
|
+
|
|
220
|
+
await db.delete(schema.oauthStates).where(eq(schema.oauthStates.stateHash, stateHash))
|
|
221
|
+
|
|
222
|
+
return state
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return adapter
|
|
227
|
+
}
|
package/src/index.ts
ADDED
package/src/schema-pg.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { pgTable, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
|
|
2
|
+
|
|
3
|
+
export const users = pgTable('users', {
|
|
4
|
+
id: text('id').primaryKey(),
|
|
5
|
+
email: text('email'),
|
|
6
|
+
emailVerified: boolean('email_verified').default(false).notNull(),
|
|
7
|
+
name: text('name'),
|
|
8
|
+
image: text('image'),
|
|
9
|
+
createdAt: timestamp('created_at').notNull(),
|
|
10
|
+
updatedAt: timestamp('updated_at').notNull(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
14
|
+
export const accounts = pgTable(
|
|
15
|
+
'accounts',
|
|
16
|
+
{
|
|
17
|
+
id: text('id').primaryKey(),
|
|
18
|
+
userId: text('user_id')
|
|
19
|
+
.notNull()
|
|
20
|
+
.references(() => users.id),
|
|
21
|
+
provider: text('provider').notNull(),
|
|
22
|
+
providerAccountId: text('provider_account_id').notNull(),
|
|
23
|
+
accessToken: text('access_token'),
|
|
24
|
+
refreshToken: text('refresh_token'),
|
|
25
|
+
idToken: text('id_token'),
|
|
26
|
+
scope: text('scope'),
|
|
27
|
+
tokenType: text('token_type'),
|
|
28
|
+
expiresAt: timestamp('expires_at'),
|
|
29
|
+
createdAt: timestamp('created_at').notNull(),
|
|
30
|
+
updatedAt: timestamp('updated_at').notNull(),
|
|
31
|
+
},
|
|
32
|
+
(table) => ({
|
|
33
|
+
providerAccountUnique: uniqueIndex('unique_provider_account').on(
|
|
34
|
+
table.provider,
|
|
35
|
+
table.providerAccountId,
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
export const sessions = pgTable('sessions', {
|
|
41
|
+
id: text('id').primaryKey(),
|
|
42
|
+
userId: text('user_id')
|
|
43
|
+
.notNull()
|
|
44
|
+
.references(() => users.id),
|
|
45
|
+
tokenHash: text('token_hash').unique().notNull(),
|
|
46
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
47
|
+
ipAddress: text('ip_address'),
|
|
48
|
+
userAgent: text('user_agent'),
|
|
49
|
+
createdAt: timestamp('created_at').notNull(),
|
|
50
|
+
updatedAt: timestamp('updated_at').notNull(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export const oauthStates = pgTable('oauth_states', {
|
|
54
|
+
id: text('id').primaryKey(),
|
|
55
|
+
stateHash: text('state_hash').unique().notNull(),
|
|
56
|
+
codeVerifier: text('code_verifier').notNull(),
|
|
57
|
+
provider: text('provider').notNull(),
|
|
58
|
+
callbackUrl: text('callback_url'),
|
|
59
|
+
errorUrl: text('error_url'),
|
|
60
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
61
|
+
createdAt: timestamp('created_at').notNull(),
|
|
62
|
+
})
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
|
|
3
|
+
export const users = sqliteTable('users', {
|
|
4
|
+
id: text('id').primaryKey(),
|
|
5
|
+
email: text('email'),
|
|
6
|
+
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
|
|
7
|
+
name: text('name'),
|
|
8
|
+
image: text('image'),
|
|
9
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
10
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
14
|
+
export const accounts = sqliteTable(
|
|
15
|
+
'accounts',
|
|
16
|
+
{
|
|
17
|
+
id: text('id').primaryKey(),
|
|
18
|
+
userId: text('user_id')
|
|
19
|
+
.notNull()
|
|
20
|
+
.references(() => users.id),
|
|
21
|
+
provider: text('provider').notNull(),
|
|
22
|
+
providerAccountId: text('provider_account_id').notNull(),
|
|
23
|
+
accessToken: text('access_token'),
|
|
24
|
+
refreshToken: text('refresh_token'),
|
|
25
|
+
idToken: text('id_token'),
|
|
26
|
+
scope: text('scope'),
|
|
27
|
+
tokenType: text('token_type'),
|
|
28
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
|
29
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
30
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
31
|
+
},
|
|
32
|
+
(table) => ({
|
|
33
|
+
providerAccountUnique: uniqueIndex('unique_provider_account').on(
|
|
34
|
+
table.provider,
|
|
35
|
+
table.providerAccountId,
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
export const sessions = sqliteTable('sessions', {
|
|
41
|
+
id: text('id').primaryKey(),
|
|
42
|
+
userId: text('user_id')
|
|
43
|
+
.notNull()
|
|
44
|
+
.references(() => users.id),
|
|
45
|
+
tokenHash: text('token_hash').unique().notNull(),
|
|
46
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
47
|
+
ipAddress: text('ip_address'),
|
|
48
|
+
userAgent: text('user_agent'),
|
|
49
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
50
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export const oauthStates = sqliteTable('oauth_states', {
|
|
54
|
+
id: text('id').primaryKey(),
|
|
55
|
+
stateHash: text('state_hash').unique().notNull(),
|
|
56
|
+
codeVerifier: text('code_verifier').notNull(),
|
|
57
|
+
provider: text('provider').notNull(),
|
|
58
|
+
callbackUrl: text('callback_url'),
|
|
59
|
+
errorUrl: text('error_url'),
|
|
60
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
61
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
62
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, beforeAll, afterAll, beforeEach } from 'vitest'
|
|
2
|
+
import { createClient } from '@libsql/client'
|
|
3
|
+
import { runAdapterConformanceTests } from '@cloudauth/core/test'
|
|
4
|
+
import { drizzleAdapter } from '../adapter.js'
|
|
5
|
+
import * as schema from '../schema.js'
|
|
6
|
+
import { drizzle } from 'drizzle-orm/libsql'
|
|
7
|
+
|
|
8
|
+
describe('Drizzle SQLite adapter', () => {
|
|
9
|
+
let client: ReturnType<typeof createClient>
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
client = createClient({
|
|
13
|
+
url: ':memory:',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Create tables once
|
|
17
|
+
await client.execute(`
|
|
18
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
19
|
+
id text PRIMARY KEY,
|
|
20
|
+
email text,
|
|
21
|
+
email_verified integer DEFAULT 0 NOT NULL,
|
|
22
|
+
name text,
|
|
23
|
+
image text,
|
|
24
|
+
created_at integer NOT NULL,
|
|
25
|
+
updated_at integer NOT NULL
|
|
26
|
+
)
|
|
27
|
+
`)
|
|
28
|
+
await client.execute(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
30
|
+
id text PRIMARY KEY,
|
|
31
|
+
user_id text NOT NULL REFERENCES users(id),
|
|
32
|
+
provider text NOT NULL,
|
|
33
|
+
provider_account_id text NOT NULL,
|
|
34
|
+
access_token text,
|
|
35
|
+
refresh_token text,
|
|
36
|
+
id_token text,
|
|
37
|
+
scope text,
|
|
38
|
+
token_type text,
|
|
39
|
+
expires_at integer,
|
|
40
|
+
created_at integer NOT NULL,
|
|
41
|
+
updated_at integer NOT NULL
|
|
42
|
+
)
|
|
43
|
+
`)
|
|
44
|
+
await client.execute(`
|
|
45
|
+
CREATE UNIQUE INDEX IF NOT EXISTS unique_provider_account
|
|
46
|
+
ON accounts(provider, provider_account_id)
|
|
47
|
+
`)
|
|
48
|
+
await client.execute(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
50
|
+
id text PRIMARY KEY,
|
|
51
|
+
user_id text NOT NULL REFERENCES users(id),
|
|
52
|
+
token_hash text UNIQUE NOT NULL,
|
|
53
|
+
expires_at integer NOT NULL,
|
|
54
|
+
ip_address text,
|
|
55
|
+
user_agent text,
|
|
56
|
+
created_at integer NOT NULL,
|
|
57
|
+
updated_at integer NOT NULL
|
|
58
|
+
)
|
|
59
|
+
`)
|
|
60
|
+
await client.execute(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS oauth_states (
|
|
62
|
+
id text PRIMARY KEY,
|
|
63
|
+
state_hash text UNIQUE NOT NULL,
|
|
64
|
+
code_verifier text NOT NULL,
|
|
65
|
+
provider text NOT NULL,
|
|
66
|
+
callback_url text,
|
|
67
|
+
error_url text,
|
|
68
|
+
expires_at integer NOT NULL,
|
|
69
|
+
created_at integer NOT NULL
|
|
70
|
+
)
|
|
71
|
+
`)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
// Clean data between tests to ensure test isolation
|
|
76
|
+
await client.execute('DELETE FROM accounts')
|
|
77
|
+
await client.execute('DELETE FROM sessions')
|
|
78
|
+
await client.execute('DELETE FROM oauth_states')
|
|
79
|
+
await client.execute('DELETE FROM users')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
client.close()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
runAdapterConformanceTests({
|
|
87
|
+
name: 'SQLite (libsql)',
|
|
88
|
+
createAdapter: () => {
|
|
89
|
+
const db = drizzle(client, { schema })
|
|
90
|
+
return drizzleAdapter(db)
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
|
2
|
+
import { createClient } from '@libsql/client'
|
|
3
|
+
import { drizzle } from 'drizzle-orm/libsql'
|
|
4
|
+
import { drizzleAdapter } from '../adapter.js'
|
|
5
|
+
import * as schema from '../schema.js'
|
|
6
|
+
import { cloudAuth } from '@cloudauth/core'
|
|
7
|
+
import type {
|
|
8
|
+
AuthProvider,
|
|
9
|
+
AuthorizationParams,
|
|
10
|
+
ExchangeCodeParams,
|
|
11
|
+
TokenSet,
|
|
12
|
+
ProviderProfile,
|
|
13
|
+
} from '@cloudauth/core'
|
|
14
|
+
|
|
15
|
+
// A mock provider for testing
|
|
16
|
+
const mockProvider: AuthProvider = {
|
|
17
|
+
id: 'mock',
|
|
18
|
+
type: 'oauth',
|
|
19
|
+
authorizationUrl(params: AuthorizationParams): URL {
|
|
20
|
+
const url = new URL('https://mock.provider/oauth/authorize')
|
|
21
|
+
url.searchParams.set('state', params.state)
|
|
22
|
+
url.searchParams.set('code_challenge', params.codeVerifier)
|
|
23
|
+
url.searchParams.set('redirect_uri', params.redirectUri)
|
|
24
|
+
return url
|
|
25
|
+
},
|
|
26
|
+
exchangeCode(_params: ExchangeCodeParams): Promise<TokenSet> {
|
|
27
|
+
return Promise.resolve({
|
|
28
|
+
accessToken: 'mock_access_token',
|
|
29
|
+
refreshToken: 'mock_refresh_token',
|
|
30
|
+
scope: 'email profile',
|
|
31
|
+
tokenType: 'Bearer',
|
|
32
|
+
expiresAt: 3600,
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
getProfile(_tokens: TokenSet): Promise<ProviderProfile> {
|
|
36
|
+
return Promise.resolve({
|
|
37
|
+
provider: 'mock',
|
|
38
|
+
providerAccountId: 'mock_user_123',
|
|
39
|
+
email: 'mock@example.com',
|
|
40
|
+
emailVerified: true,
|
|
41
|
+
name: 'Mock User',
|
|
42
|
+
image: 'https://example.com/avatar.png',
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getCookieValue(cookieHeader: string): string | null {
|
|
48
|
+
const firstPart = cookieHeader.split(';')[0]
|
|
49
|
+
if (!firstPart) return null
|
|
50
|
+
const eqIndex = firstPart.indexOf('=')
|
|
51
|
+
if (eqIndex === -1) return null
|
|
52
|
+
return firstPart.slice(eqIndex + 1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('cloudAuth integration', () => {
|
|
56
|
+
let client: ReturnType<typeof createClient>
|
|
57
|
+
let auth: ReturnType<typeof cloudAuth>
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
client = createClient({ url: ':memory:' })
|
|
61
|
+
|
|
62
|
+
await client.execute(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
64
|
+
id text PRIMARY KEY, email text, email_verified integer DEFAULT 0 NOT NULL,
|
|
65
|
+
name text, image text, created_at integer NOT NULL, updated_at integer NOT NULL
|
|
66
|
+
)
|
|
67
|
+
`)
|
|
68
|
+
await client.execute(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
70
|
+
id text PRIMARY KEY, user_id text NOT NULL REFERENCES users(id),
|
|
71
|
+
provider text NOT NULL, provider_account_id text NOT NULL,
|
|
72
|
+
access_token text, refresh_token text, id_token text,
|
|
73
|
+
scope text, token_type text, expires_at integer,
|
|
74
|
+
created_at integer NOT NULL, updated_at integer NOT NULL
|
|
75
|
+
)
|
|
76
|
+
`)
|
|
77
|
+
await client.execute(`
|
|
78
|
+
CREATE UNIQUE INDEX IF NOT EXISTS unique_provider_account
|
|
79
|
+
ON accounts(provider, provider_account_id)
|
|
80
|
+
`)
|
|
81
|
+
await client.execute(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
83
|
+
id text PRIMARY KEY, user_id text NOT NULL REFERENCES users(id),
|
|
84
|
+
token_hash text UNIQUE NOT NULL, expires_at integer NOT NULL,
|
|
85
|
+
ip_address text, user_agent text,
|
|
86
|
+
created_at integer NOT NULL, updated_at integer NOT NULL
|
|
87
|
+
)
|
|
88
|
+
`)
|
|
89
|
+
await client.execute(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS oauth_states (
|
|
91
|
+
id text PRIMARY KEY, state_hash text UNIQUE NOT NULL,
|
|
92
|
+
code_verifier text NOT NULL, provider text NOT NULL,
|
|
93
|
+
callback_url text, error_url text,
|
|
94
|
+
expires_at integer NOT NULL, created_at integer NOT NULL
|
|
95
|
+
)
|
|
96
|
+
`)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
await client.execute('DELETE FROM accounts')
|
|
101
|
+
await client.execute('DELETE FROM sessions')
|
|
102
|
+
await client.execute('DELETE FROM oauth_states')
|
|
103
|
+
await client.execute('DELETE FROM users')
|
|
104
|
+
|
|
105
|
+
const db = drizzle(client, { schema })
|
|
106
|
+
auth = cloudAuth({
|
|
107
|
+
appName: 'TestApp',
|
|
108
|
+
baseURL: 'https://test.example.com',
|
|
109
|
+
providers: [mockProvider],
|
|
110
|
+
database: drizzleAdapter(db),
|
|
111
|
+
security: { secret: 'test-secret-that-is-at-least-32-characters!!' },
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
client.close()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should create an authorization URL', async () => {
|
|
120
|
+
const url = await auth.createAuthUrl('mock')
|
|
121
|
+
expect(url).toBeInstanceOf(URL)
|
|
122
|
+
expect(url.hostname).toBe('mock.provider')
|
|
123
|
+
expect(url.searchParams.has('state')).toBe(true)
|
|
124
|
+
expect(url.searchParams.has('code_challenge')).toBe(true)
|
|
125
|
+
expect(url.searchParams.has('redirect_uri')).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should handle a full sign-in callback', async () => {
|
|
129
|
+
const authUrl = await auth.createAuthUrl('mock')
|
|
130
|
+
const stateParam = authUrl.searchParams.get('state')
|
|
131
|
+
expect(stateParam).toBeTruthy()
|
|
132
|
+
|
|
133
|
+
const callbackUrl = `https://test.example.com/auth/mock/callback?code=mock_code&state=${String(stateParam)}`
|
|
134
|
+
const result = await auth.handleCallback('mock', new Request(callbackUrl))
|
|
135
|
+
|
|
136
|
+
expect(result.user).toBeDefined()
|
|
137
|
+
expect(result.user.email).toBe('mock@example.com')
|
|
138
|
+
expect(result.session).toBeDefined()
|
|
139
|
+
expect(result.cookieHeader).toContain('cloud_auth_session')
|
|
140
|
+
expect(result.cookieHeader).toContain('HttpOnly')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should get a valid session after sign-in', async () => {
|
|
144
|
+
const authUrl = await auth.createAuthUrl('mock')
|
|
145
|
+
const stateParam = authUrl.searchParams.get('state')
|
|
146
|
+
expect(stateParam).toBeTruthy()
|
|
147
|
+
const callbackUrl = `https://test.example.com/auth/mock/callback?code=mock_code&state=${String(stateParam)}`
|
|
148
|
+
const result = await auth.handleCallback('mock', new Request(callbackUrl))
|
|
149
|
+
|
|
150
|
+
const cookieValue = getCookieValue(result.cookieHeader)
|
|
151
|
+
expect(cookieValue).toBeTruthy()
|
|
152
|
+
|
|
153
|
+
const sessionRequest = new Request('https://test.example.com/dashboard', {
|
|
154
|
+
headers: { cookie: `cloud_auth_session=${String(cookieValue)}` },
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const session = await auth.getSession(sessionRequest)
|
|
158
|
+
expect(session).not.toBeNull()
|
|
159
|
+
if (session) {
|
|
160
|
+
expect(session.user.email).toBe('mock@example.com')
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should return null for unauthenticated requests', async () => {
|
|
165
|
+
const req = new Request('https://test.example.com/protected')
|
|
166
|
+
const session = await auth.getSession(req)
|
|
167
|
+
expect(session).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should sign out and clear the session', async () => {
|
|
171
|
+
const authUrl = await auth.createAuthUrl('mock')
|
|
172
|
+
const stateParam = authUrl.searchParams.get('state')
|
|
173
|
+
expect(stateParam).toBeTruthy()
|
|
174
|
+
const callbackUrl = `https://test.example.com/auth/mock/callback?code=mock_code&state=${String(stateParam)}`
|
|
175
|
+
const result = await auth.handleCallback('mock', new Request(callbackUrl))
|
|
176
|
+
const cookieValue = getCookieValue(result.cookieHeader)
|
|
177
|
+
expect(cookieValue).toBeTruthy()
|
|
178
|
+
|
|
179
|
+
const signOutReq = new Request('https://test.example.com/logout', {
|
|
180
|
+
headers: { cookie: `cloud_auth_session=${String(cookieValue)}` },
|
|
181
|
+
})
|
|
182
|
+
const clearCookie = await auth.signOut(signOutReq)
|
|
183
|
+
|
|
184
|
+
expect(clearCookie).toContain('Max-Age=0')
|
|
185
|
+
|
|
186
|
+
const checkReq = new Request('https://test.example.com/dashboard', {
|
|
187
|
+
headers: { cookie: `cloud_auth_session=${String(cookieValue)}` },
|
|
188
|
+
})
|
|
189
|
+
const session = await auth.getSession(checkReq)
|
|
190
|
+
expect(session).toBeNull()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should throw on invalid state', async () => {
|
|
194
|
+
const callbackUrl = 'https://test.example.com/auth/mock/callback?code=bad&state=invalid'
|
|
195
|
+
await expect(auth.handleCallback('mock', new Request(callbackUrl))).rejects.toThrow()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('should throw on missing security secret', async () => {
|
|
199
|
+
const db = drizzle(client, { schema })
|
|
200
|
+
const badAuth = cloudAuth({
|
|
201
|
+
appName: 'Test',
|
|
202
|
+
baseURL: 'https://test.com',
|
|
203
|
+
providers: [mockProvider],
|
|
204
|
+
database: drizzleAdapter(db),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await expect(badAuth.getSession(new Request('https://test.com'))).rejects.toThrow(
|
|
208
|
+
'security.secret is required',
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
})
|