@oidcraft/kysely 0.0.0-stage → 0.2.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/README.md +26 -2
- package/dist/index.d.mts +117 -0
- package/dist/index.mjs +458 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Chiș
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oidcraft/kysely
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [Kysely](https://kysely.dev) storage adapter for `@oidcraft/provider`, on SQLite or Postgres.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bun add @oidcraft/kysely @oidcraft/adapter kysely
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { staticKeyStore } from '@oidcraft/adapter'
|
|
11
|
+
import { kyselyAdapter } from '@oidcraft/kysely'
|
|
12
|
+
|
|
13
|
+
const adapter = kyselyAdapter({ db, keys: staticKeyStore(keys) }) // db: Kysely<OidcraftDatabase>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Table names are unqualified, so pick a Postgres schema with the search path or `db.withSchema(...)`.
|
|
17
|
+
|
|
18
|
+
The schema ships as migrations, `SQLITE_MIGRATIONS` and `POSTGRES_MIGRATIONS` — the same tables as
|
|
19
|
+
the other SQL adapters, so one database works with any of them — which you run with your own tooling
|
|
20
|
+
before you deploy. The adapter never
|
|
21
|
+
creates or alters a table at startup.
|
|
22
|
+
|
|
23
|
+
It returns a `FullAdapter`, so it satisfies every `@oidcraft/provider` entry point: brokering, local
|
|
24
|
+
accounts and realms included. It passes the shared suite in `@oidcraft/adapter/conformance`, on
|
|
25
|
+
SQLite and Postgres.
|
|
26
|
+
|
|
27
|
+
See the [storage guide](https://github.com/Fl0r14n/oidcraft/blob/main/apps/docs/guide/adapters.md).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AccountStore, FullAdapter, KeyStore } from "@oidcraft/adapter";
|
|
2
|
+
import { ColumnType, Generated, Kysely } from "kysely";
|
|
3
|
+
/** Milliseconds since the epoch: SQLite has no date type. Postgres keeps them in `bigint`, which it reads back as a string. */
|
|
4
|
+
type Millis = ColumnType<number | string, number, number>;
|
|
5
|
+
export type ClientsTable = {
|
|
6
|
+
client_id: string;
|
|
7
|
+
data: string;
|
|
8
|
+
created_at: Millis;
|
|
9
|
+
updated_at: Millis;
|
|
10
|
+
};
|
|
11
|
+
export type ArtifactsTable = {
|
|
12
|
+
kind: string;
|
|
13
|
+
id: string;
|
|
14
|
+
client_id: string;
|
|
15
|
+
account_id: string | null;
|
|
16
|
+
grant_id: string | null;
|
|
17
|
+
family_id: string | null;
|
|
18
|
+
user_code: string | null;
|
|
19
|
+
payload: string;
|
|
20
|
+
consumed_at: Millis | null;
|
|
21
|
+
expires_at: Millis;
|
|
22
|
+
};
|
|
23
|
+
export type SessionsTable = {
|
|
24
|
+
id: string;
|
|
25
|
+
account_id: string;
|
|
26
|
+
auth_time: Millis;
|
|
27
|
+
acr: string | null;
|
|
28
|
+
amr: string | null;
|
|
29
|
+
idp: string | null;
|
|
30
|
+
upstream_session_id: string | null;
|
|
31
|
+
clients: string;
|
|
32
|
+
expires_at: Millis;
|
|
33
|
+
};
|
|
34
|
+
export type GrantsTable = {
|
|
35
|
+
id: string;
|
|
36
|
+
account_id: string;
|
|
37
|
+
client_id: string;
|
|
38
|
+
scopes: string;
|
|
39
|
+
claims: string;
|
|
40
|
+
resources: string;
|
|
41
|
+
created_at: Millis;
|
|
42
|
+
expires_at: Millis | null;
|
|
43
|
+
};
|
|
44
|
+
export type IdentitiesTable = {
|
|
45
|
+
provider: string;
|
|
46
|
+
subject: string;
|
|
47
|
+
account_id: string;
|
|
48
|
+
email: string | null;
|
|
49
|
+
claims: string;
|
|
50
|
+
linked_at: Millis;
|
|
51
|
+
};
|
|
52
|
+
export type ReplayTable = {
|
|
53
|
+
namespace: string;
|
|
54
|
+
value: string;
|
|
55
|
+
expires_at: Millis;
|
|
56
|
+
};
|
|
57
|
+
export type CredentialsTable = {
|
|
58
|
+
account_id: string;
|
|
59
|
+
username: string;
|
|
60
|
+
password_hash: string;
|
|
61
|
+
claims: string;
|
|
62
|
+
totp_secret: string | null;
|
|
63
|
+
totp_confirmed_at: Millis | null;
|
|
64
|
+
recovery_codes: string | null;
|
|
65
|
+
failed_attempts: number;
|
|
66
|
+
locked_until: Millis | null;
|
|
67
|
+
created_at: Millis;
|
|
68
|
+
updated_at: Millis;
|
|
69
|
+
};
|
|
70
|
+
export type RealmsTable = {
|
|
71
|
+
id: string;
|
|
72
|
+
display_name: string | null;
|
|
73
|
+
/** 0 or 1: SQLite has no boolean. */
|
|
74
|
+
enabled: number;
|
|
75
|
+
settings: string;
|
|
76
|
+
created_at: Millis;
|
|
77
|
+
updated_at: Millis;
|
|
78
|
+
};
|
|
79
|
+
export type OidcraftDatabase = {
|
|
80
|
+
oidc_clients: ClientsTable;
|
|
81
|
+
oidc_artifacts: ArtifactsTable;
|
|
82
|
+
oidc_sessions: SessionsTable;
|
|
83
|
+
oidc_grants: GrantsTable;
|
|
84
|
+
oidc_identities: IdentitiesTable;
|
|
85
|
+
oidc_replay: ReplayTable;
|
|
86
|
+
oidc_credentials: CredentialsTable;
|
|
87
|
+
oidc_realms: RealmsTable;
|
|
88
|
+
};
|
|
89
|
+
type Migration = {
|
|
90
|
+
id: string;
|
|
91
|
+
statements: string[];
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The same tables as `SQLITE_MIGRATIONS`, with the same ids, for Postgres (FR-A4). Milliseconds are
|
|
95
|
+
* `bigint` and JSON stays `text`, so every adapter reads and writes exactly what it does on SQLite.
|
|
96
|
+
* Table names are unqualified: they land in whatever schema the host migrates into, which is how a
|
|
97
|
+
* realm gets a schema of its own in one database (G-6).
|
|
98
|
+
*/
|
|
99
|
+
export declare const POSTGRES_MIGRATIONS: Migration[];
|
|
100
|
+
/**
|
|
101
|
+
* The SQLite schema every SQL adapter queries, as ordered migrations the host applies with its own
|
|
102
|
+
* tooling. The library never runs them: a library that migrates on import migrates during a rolling
|
|
103
|
+
* deploy, from several processes at once (ARCHITECTURE.md §4.2, FR-A4).
|
|
104
|
+
*/
|
|
105
|
+
export declare const SQLITE_MIGRATIONS: Migration[];
|
|
106
|
+
export type KyselyAdapterOptions = {
|
|
107
|
+
db: Kysely<OidcraftDatabase>;
|
|
108
|
+
keys: KeyStore;
|
|
109
|
+
/** Omitted, every account lookup misses: the host composes one in later, as `@oidcraft/provider/accounts` does. */
|
|
110
|
+
accounts?: AccountStore;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The same contract as the Drizzle adapter, on a query builder rather than an ORM (FR-A4). Both run
|
|
114
|
+
* the one conformance suite, which is what makes "interchangeable" checkable rather than asserted.
|
|
115
|
+
*/
|
|
116
|
+
export declare const kyselyAdapter: (options: KyselyAdapterOptions) => FullAdapter;
|
|
117
|
+
export type { Generated, Migration };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { noAccounts } from "@oidcraft/adapter";
|
|
2
|
+
// ../sql/dist/rows.mjs
|
|
3
|
+
/** Never `new Date` on the raw value: a string there is read as a date, not as milliseconds. */
|
|
4
|
+
const at = (millis) => new Date(Number(millis));
|
|
5
|
+
const json = (value) => JSON.stringify(value);
|
|
6
|
+
const parse = (value) => JSON.parse(value);
|
|
7
|
+
const clientRow = (client) => ({
|
|
8
|
+
client_id: client.clientId,
|
|
9
|
+
data: json(client),
|
|
10
|
+
created_at: client.createdAt.getTime(),
|
|
11
|
+
updated_at: client.updatedAt.getTime()
|
|
12
|
+
});
|
|
13
|
+
const toClient = (row) => ({
|
|
14
|
+
...parse(row.data),
|
|
15
|
+
createdAt: at(row.created_at),
|
|
16
|
+
updatedAt: at(row.updated_at)
|
|
17
|
+
});
|
|
18
|
+
/** `user_code` is lifted out of the payload so a device's code is found by index (FR-C7). */
|
|
19
|
+
const artifactRow = (artifact) => ({
|
|
20
|
+
kind: artifact.kind,
|
|
21
|
+
id: artifact.id,
|
|
22
|
+
client_id: artifact.clientId,
|
|
23
|
+
account_id: artifact.accountId ?? null,
|
|
24
|
+
grant_id: artifact.grantId ?? null,
|
|
25
|
+
family_id: artifact.familyId ?? null,
|
|
26
|
+
user_code: artifact.payload.userCode ?? null,
|
|
27
|
+
payload: json(artifact.payload),
|
|
28
|
+
consumed_at: artifact.consumedAt ? artifact.consumedAt.getTime() : null,
|
|
29
|
+
expires_at: artifact.expiresAt.getTime()
|
|
30
|
+
});
|
|
31
|
+
const toArtifact = (row) => ({
|
|
32
|
+
id: row.id,
|
|
33
|
+
kind: row.kind,
|
|
34
|
+
clientId: row.client_id,
|
|
35
|
+
...row.account_id && { accountId: row.account_id },
|
|
36
|
+
...row.grant_id && { grantId: row.grant_id },
|
|
37
|
+
...row.family_id && { familyId: row.family_id },
|
|
38
|
+
payload: parse(row.payload),
|
|
39
|
+
...row.consumed_at && { consumedAt: at(row.consumed_at) },
|
|
40
|
+
expiresAt: at(row.expires_at)
|
|
41
|
+
});
|
|
42
|
+
const sessionRow = (session) => ({
|
|
43
|
+
id: session.id,
|
|
44
|
+
account_id: session.accountId,
|
|
45
|
+
auth_time: session.authTime.getTime(),
|
|
46
|
+
acr: session.acr ?? null,
|
|
47
|
+
amr: session.amr ? json(session.amr) : null,
|
|
48
|
+
idp: session.idp ?? null,
|
|
49
|
+
upstream_session_id: session.upstreamSessionId ?? null,
|
|
50
|
+
clients: json(session.clients),
|
|
51
|
+
expires_at: session.expiresAt.getTime()
|
|
52
|
+
});
|
|
53
|
+
const toSession = (row) => ({
|
|
54
|
+
id: row.id,
|
|
55
|
+
accountId: row.account_id,
|
|
56
|
+
authTime: at(row.auth_time),
|
|
57
|
+
...row.acr && { acr: row.acr },
|
|
58
|
+
...row.amr ? { amr: parse(row.amr) } : {},
|
|
59
|
+
...row.idp && { idp: row.idp },
|
|
60
|
+
...row.upstream_session_id && { upstreamSessionId: row.upstream_session_id },
|
|
61
|
+
clients: parse(row.clients),
|
|
62
|
+
expiresAt: at(row.expires_at)
|
|
63
|
+
});
|
|
64
|
+
const grantRow = (grant) => ({
|
|
65
|
+
id: grant.id,
|
|
66
|
+
account_id: grant.accountId,
|
|
67
|
+
client_id: grant.clientId,
|
|
68
|
+
scopes: json(grant.scopes),
|
|
69
|
+
claims: json(grant.claims),
|
|
70
|
+
resources: json(grant.resources),
|
|
71
|
+
created_at: grant.createdAt.getTime(),
|
|
72
|
+
expires_at: grant.expiresAt ? grant.expiresAt.getTime() : null
|
|
73
|
+
});
|
|
74
|
+
const toGrant = (row) => ({
|
|
75
|
+
id: row.id,
|
|
76
|
+
accountId: row.account_id,
|
|
77
|
+
clientId: row.client_id,
|
|
78
|
+
scopes: parse(row.scopes),
|
|
79
|
+
claims: parse(row.claims),
|
|
80
|
+
resources: parse(row.resources),
|
|
81
|
+
createdAt: at(row.created_at),
|
|
82
|
+
...row.expires_at && { expiresAt: at(row.expires_at) }
|
|
83
|
+
});
|
|
84
|
+
const identityRow = (identity) => ({
|
|
85
|
+
provider: identity.provider,
|
|
86
|
+
subject: identity.subject,
|
|
87
|
+
account_id: identity.accountId,
|
|
88
|
+
email: identity.email ?? null,
|
|
89
|
+
claims: json(identity.claims),
|
|
90
|
+
linked_at: identity.linkedAt.getTime()
|
|
91
|
+
});
|
|
92
|
+
const toIdentity = (row) => ({
|
|
93
|
+
accountId: row.account_id,
|
|
94
|
+
provider: row.provider,
|
|
95
|
+
subject: row.subject,
|
|
96
|
+
...row.email && { email: row.email },
|
|
97
|
+
claims: parse(row.claims),
|
|
98
|
+
linkedAt: at(row.linked_at)
|
|
99
|
+
});
|
|
100
|
+
const credentialRow = (credential) => ({
|
|
101
|
+
account_id: credential.accountId,
|
|
102
|
+
username: credential.username,
|
|
103
|
+
password_hash: credential.passwordHash,
|
|
104
|
+
claims: json(credential.claims),
|
|
105
|
+
totp_secret: credential.totp?.secret ?? null,
|
|
106
|
+
totp_confirmed_at: credential.totp?.confirmedAt?.getTime() ?? null,
|
|
107
|
+
recovery_codes: credential.recoveryCodes ? json(credential.recoveryCodes) : null,
|
|
108
|
+
failed_attempts: credential.failedAttempts,
|
|
109
|
+
locked_until: credential.lockedUntil?.getTime() ?? null,
|
|
110
|
+
created_at: credential.createdAt.getTime(),
|
|
111
|
+
updated_at: credential.updatedAt.getTime()
|
|
112
|
+
});
|
|
113
|
+
const toCredential = (row) => ({
|
|
114
|
+
accountId: row.account_id,
|
|
115
|
+
username: row.username,
|
|
116
|
+
passwordHash: row.password_hash,
|
|
117
|
+
claims: parse(row.claims),
|
|
118
|
+
...row.totp_secret && { totp: {
|
|
119
|
+
secret: row.totp_secret,
|
|
120
|
+
...row.totp_confirmed_at && { confirmedAt: at(row.totp_confirmed_at) }
|
|
121
|
+
} },
|
|
122
|
+
...row.recovery_codes ? { recoveryCodes: parse(row.recovery_codes) } : {},
|
|
123
|
+
failedAttempts: Number(row.failed_attempts),
|
|
124
|
+
...row.locked_until && { lockedUntil: at(row.locked_until) },
|
|
125
|
+
createdAt: at(row.created_at),
|
|
126
|
+
updatedAt: at(row.updated_at)
|
|
127
|
+
});
|
|
128
|
+
const realmRow = (realm) => ({
|
|
129
|
+
id: realm.id,
|
|
130
|
+
display_name: realm.displayName ?? null,
|
|
131
|
+
enabled: realm.enabled ? 1 : 0,
|
|
132
|
+
settings: json(realm.settings),
|
|
133
|
+
created_at: realm.createdAt.getTime(),
|
|
134
|
+
updated_at: realm.updatedAt.getTime()
|
|
135
|
+
});
|
|
136
|
+
const toRealm = (row) => ({
|
|
137
|
+
id: row.id,
|
|
138
|
+
...row.display_name && { displayName: row.display_name },
|
|
139
|
+
enabled: Boolean(Number(row.enabled)),
|
|
140
|
+
settings: parse(row.settings),
|
|
141
|
+
createdAt: at(row.created_at),
|
|
142
|
+
updatedAt: at(row.updated_at)
|
|
143
|
+
});
|
|
144
|
+
// ../sql/dist/postgres.mjs
|
|
145
|
+
/**
|
|
146
|
+
* The same tables as `SQLITE_MIGRATIONS`, with the same ids, for Postgres (FR-A4). Milliseconds are
|
|
147
|
+
* `bigint` and JSON stays `text`, so every adapter reads and writes exactly what it does on SQLite.
|
|
148
|
+
* Table names are unqualified: they land in whatever schema the host migrates into, which is how a
|
|
149
|
+
* realm gets a schema of its own in one database (G-6).
|
|
150
|
+
*/
|
|
151
|
+
const POSTGRES_MIGRATIONS = [
|
|
152
|
+
{
|
|
153
|
+
id: "0001_init",
|
|
154
|
+
statements: [
|
|
155
|
+
"create table oidc_clients (client_id text primary key, data text not null, created_at bigint not null, updated_at bigint not null)",
|
|
156
|
+
"create table oidc_artifacts (kind text not null, id text not null, client_id text not null, account_id text, grant_id text, user_code text, payload text not null, consumed_at bigint, expires_at bigint not null, primary key (kind, id))",
|
|
157
|
+
"create index oidc_artifacts_grant_idx on oidc_artifacts (grant_id)",
|
|
158
|
+
"create index oidc_artifacts_user_code_idx on oidc_artifacts (user_code)",
|
|
159
|
+
"create index oidc_artifacts_expiry_idx on oidc_artifacts (expires_at)",
|
|
160
|
+
"create table oidc_sessions (id text primary key, account_id text not null, auth_time bigint not null, acr text, amr text, idp text, upstream_session_id text, clients text not null, expires_at bigint not null)",
|
|
161
|
+
"create index oidc_sessions_account_idx on oidc_sessions (account_id)",
|
|
162
|
+
"create index oidc_sessions_upstream_idx on oidc_sessions (idp, upstream_session_id)",
|
|
163
|
+
"create table oidc_grants (id text primary key, account_id text not null, client_id text not null, scopes text not null, claims text not null, resources text not null, created_at bigint not null, expires_at bigint)",
|
|
164
|
+
"create unique index oidc_grants_account_client_idx on oidc_grants (account_id, client_id)",
|
|
165
|
+
"create table oidc_identities (provider text not null, subject text not null, account_id text not null, email text, claims text not null, linked_at bigint not null, primary key (provider, subject))",
|
|
166
|
+
"create table oidc_replay (namespace text not null, value text not null, expires_at bigint not null, primary key (namespace, value))"
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "0002_credentials",
|
|
171
|
+
statements: [
|
|
172
|
+
"create table oidc_credentials (account_id text primary key, username text not null, password_hash text not null, claims text not null, totp_secret text, totp_confirmed_at bigint, recovery_codes text, failed_attempts integer not null default 0, locked_until bigint, created_at bigint not null, updated_at bigint not null)",
|
|
173
|
+
"create unique index oidc_credentials_username_idx on oidc_credentials (username)",
|
|
174
|
+
"create index oidc_identities_account_idx on oidc_identities (account_id)",
|
|
175
|
+
"create index oidc_replay_expiry_idx on oidc_replay (expires_at)"
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "0003_realms",
|
|
180
|
+
statements: ["create table oidc_realms (id text primary key, display_name text, enabled integer not null, settings text not null, created_at bigint not null, updated_at bigint not null)"]
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "0004_indexes",
|
|
184
|
+
statements: [
|
|
185
|
+
"create index oidc_artifacts_client_idx on oidc_artifacts (client_id)",
|
|
186
|
+
"create index oidc_grants_client_idx on oidc_grants (client_id)",
|
|
187
|
+
"create index oidc_sessions_expiry_idx on oidc_sessions (expires_at)"
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "0005_families",
|
|
192
|
+
statements: ["alter table oidc_artifacts add column family_id text", "create index oidc_artifacts_family_idx on oidc_artifacts (family_id)"]
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
// ../sql/dist/sqlite.mjs
|
|
196
|
+
/**
|
|
197
|
+
* The SQLite schema every SQL adapter queries, as ordered migrations the host applies with its own
|
|
198
|
+
* tooling. The library never runs them: a library that migrates on import migrates during a rolling
|
|
199
|
+
* deploy, from several processes at once (ARCHITECTURE.md §4.2, FR-A4).
|
|
200
|
+
*/
|
|
201
|
+
const SQLITE_MIGRATIONS = [
|
|
202
|
+
{
|
|
203
|
+
id: "0001_init",
|
|
204
|
+
statements: [
|
|
205
|
+
"create table oidc_clients (client_id text primary key, data text not null, created_at integer not null, updated_at integer not null)",
|
|
206
|
+
"create table oidc_artifacts (kind text not null, id text not null, client_id text not null, account_id text, grant_id text, user_code text, payload text not null, consumed_at integer, expires_at integer not null, primary key (kind, id))",
|
|
207
|
+
"create index oidc_artifacts_grant_idx on oidc_artifacts (grant_id)",
|
|
208
|
+
"create index oidc_artifacts_user_code_idx on oidc_artifacts (user_code)",
|
|
209
|
+
"create index oidc_artifacts_expiry_idx on oidc_artifacts (expires_at)",
|
|
210
|
+
"create table oidc_sessions (id text primary key, account_id text not null, auth_time integer not null, acr text, amr text, idp text, upstream_session_id text, clients text not null, expires_at integer not null)",
|
|
211
|
+
"create index oidc_sessions_account_idx on oidc_sessions (account_id)",
|
|
212
|
+
"create index oidc_sessions_upstream_idx on oidc_sessions (idp, upstream_session_id)",
|
|
213
|
+
"create table oidc_grants (id text primary key, account_id text not null, client_id text not null, scopes text not null, claims text not null, resources text not null, created_at integer not null, expires_at integer)",
|
|
214
|
+
"create unique index oidc_grants_account_client_idx on oidc_grants (account_id, client_id)",
|
|
215
|
+
"create table oidc_identities (provider text not null, subject text not null, account_id text not null, email text, claims text not null, linked_at integer not null, primary key (provider, subject))",
|
|
216
|
+
"create table oidc_replay (namespace text not null, value text not null, expires_at integer not null, primary key (namespace, value))"
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "0002_credentials",
|
|
221
|
+
statements: [
|
|
222
|
+
"create table oidc_credentials (account_id text primary key, username text not null, password_hash text not null, claims text not null, totp_secret text, totp_confirmed_at integer, recovery_codes text, failed_attempts integer not null default 0, locked_until integer, created_at integer not null, updated_at integer not null)",
|
|
223
|
+
"create unique index oidc_credentials_username_idx on oidc_credentials (username)",
|
|
224
|
+
"create index oidc_identities_account_idx on oidc_identities (account_id)",
|
|
225
|
+
"create index oidc_replay_expiry_idx on oidc_replay (expires_at)"
|
|
226
|
+
]
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "0003_realms",
|
|
230
|
+
statements: ["create table oidc_realms (id text primary key, display_name text, enabled integer not null, settings text not null, created_at integer not null, updated_at integer not null)"]
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "0004_indexes",
|
|
234
|
+
statements: [
|
|
235
|
+
"create index oidc_artifacts_client_idx on oidc_artifacts (client_id)",
|
|
236
|
+
"create index oidc_grants_client_idx on oidc_grants (client_id)",
|
|
237
|
+
"create index oidc_sessions_expiry_idx on oidc_sessions (expires_at)"
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "0005_families",
|
|
242
|
+
statements: ["alter table oidc_artifacts add column family_id text", "create index oidc_artifacts_family_idx on oidc_artifacts (family_id)"]
|
|
243
|
+
}
|
|
244
|
+
];
|
|
245
|
+
// src/index.ts
|
|
246
|
+
const now = () => Date.now();
|
|
247
|
+
/**
|
|
248
|
+
* The same contract as the Drizzle adapter, on a query builder rather than an ORM (FR-A4). Both run
|
|
249
|
+
* the one conformance suite, which is what makes "interchangeable" checkable rather than asserted.
|
|
250
|
+
*/
|
|
251
|
+
const kyselyAdapter = (options) => {
|
|
252
|
+
const { db } = options;
|
|
253
|
+
const artifacts = {
|
|
254
|
+
async upsert(artifact) {
|
|
255
|
+
const row = artifactRow(artifact);
|
|
256
|
+
await db.insertInto("oidc_artifacts").values(row).onConflict((conflict) => conflict.columns(["kind", "id"]).doUpdateSet({
|
|
257
|
+
...row,
|
|
258
|
+
consumed_at: void 0
|
|
259
|
+
})).execute();
|
|
260
|
+
},
|
|
261
|
+
async find(kind, id) {
|
|
262
|
+
const row = await db.selectFrom("oidc_artifacts").selectAll().where("kind", "=", kind).where("id", "=", id).where("expires_at", ">", now()).executeTakeFirst();
|
|
263
|
+
return row ? toArtifact(row) : void 0;
|
|
264
|
+
},
|
|
265
|
+
async findByUserCode(userCode) {
|
|
266
|
+
const row = await db.selectFrom("oidc_artifacts").selectAll().where("user_code", "=", userCode).where("expires_at", ">", now()).executeTakeFirst();
|
|
267
|
+
return row ? toArtifact(row) : void 0;
|
|
268
|
+
},
|
|
269
|
+
async consume(kind, id) {
|
|
270
|
+
return await db.updateTable("oidc_artifacts").set({ consumed_at: now() }).where("kind", "=", kind).where("id", "=", id).where("consumed_at", "is", null).where("expires_at", ">", now()).returning("id").executeTakeFirst() !== void 0;
|
|
271
|
+
},
|
|
272
|
+
async destroy(kind, id) {
|
|
273
|
+
await db.deleteFrom("oidc_artifacts").where("kind", "=", kind).where("id", "=", id).execute();
|
|
274
|
+
},
|
|
275
|
+
async revokeByGrantId(grantId) {
|
|
276
|
+
await db.deleteFrom("oidc_artifacts").where("grant_id", "=", grantId).execute();
|
|
277
|
+
},
|
|
278
|
+
async revokeByFamilyId(familyId) {
|
|
279
|
+
await db.deleteFrom("oidc_artifacts").where("family_id", "=", familyId).execute();
|
|
280
|
+
},
|
|
281
|
+
async revokeByClientId(clientId) {
|
|
282
|
+
await db.deleteFrom("oidc_artifacts").where("client_id", "=", clientId).execute();
|
|
283
|
+
},
|
|
284
|
+
async prune(before, limit) {
|
|
285
|
+
const doomed = await db.selectFrom("oidc_artifacts").select(["kind", "id"]).where("expires_at", "<=", before.getTime()).limit(limit).execute();
|
|
286
|
+
for (const row of doomed) await artifacts.destroy(row.kind, row.id);
|
|
287
|
+
return doomed.length;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
clients: {
|
|
292
|
+
async find(clientId) {
|
|
293
|
+
const row = await db.selectFrom("oidc_clients").selectAll().where("client_id", "=", clientId).executeTakeFirst();
|
|
294
|
+
if (!row) return void 0;
|
|
295
|
+
return toClient(row);
|
|
296
|
+
},
|
|
297
|
+
async upsert(client) {
|
|
298
|
+
const row = clientRow(client);
|
|
299
|
+
const { data, updated_at } = row;
|
|
300
|
+
await db.insertInto("oidc_clients").values(row).onConflict((conflict) => conflict.column("client_id").doUpdateSet({
|
|
301
|
+
data,
|
|
302
|
+
updated_at
|
|
303
|
+
})).execute();
|
|
304
|
+
},
|
|
305
|
+
async destroy(clientId) {
|
|
306
|
+
await db.deleteFrom("oidc_clients").where("client_id", "=", clientId).execute();
|
|
307
|
+
},
|
|
308
|
+
async list(cursor, limit) {
|
|
309
|
+
let query = db.selectFrom("oidc_clients").selectAll().orderBy("client_id");
|
|
310
|
+
if (cursor !== void 0) query = query.where("client_id", ">", cursor);
|
|
311
|
+
const rows = await (limit === void 0 ? query : query.limit(limit + 1)).execute();
|
|
312
|
+
const more = limit !== void 0 && rows.length > limit;
|
|
313
|
+
const shown = more ? rows.slice(0, limit) : rows;
|
|
314
|
+
return {
|
|
315
|
+
clients: shown.map(toClient),
|
|
316
|
+
...more && { cursor: shown.at(-1)?.client_id }
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
artifacts,
|
|
321
|
+
sessions: {
|
|
322
|
+
async find(id) {
|
|
323
|
+
const row = await db.selectFrom("oidc_sessions").selectAll().where("id", "=", id).where("expires_at", ">", now()).executeTakeFirst();
|
|
324
|
+
return row ? toSession(row) : void 0;
|
|
325
|
+
},
|
|
326
|
+
async listForAccount(accountId) {
|
|
327
|
+
return (await db.selectFrom("oidc_sessions").selectAll().where("account_id", "=", accountId).where("expires_at", ">", now()).execute()).map(toSession);
|
|
328
|
+
},
|
|
329
|
+
async findByUpstreamSession(provider, upstreamSessionId) {
|
|
330
|
+
return (await db.selectFrom("oidc_sessions").selectAll().where("idp", "=", provider).where("upstream_session_id", "=", upstreamSessionId).where("expires_at", ">", now()).execute()).map(toSession);
|
|
331
|
+
},
|
|
332
|
+
async upsert(session) {
|
|
333
|
+
const row = sessionRow(session);
|
|
334
|
+
await db.insertInto("oidc_sessions").values(row).onConflict((conflict) => conflict.column("id").doUpdateSet(row)).execute();
|
|
335
|
+
},
|
|
336
|
+
async addClient(id, clientId) {
|
|
337
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
338
|
+
const row = await db.selectFrom("oidc_sessions").select("clients").where("id", "=", id).executeTakeFirst();
|
|
339
|
+
if (!row) return;
|
|
340
|
+
const current = JSON.parse(row.clients);
|
|
341
|
+
if (current.includes(clientId)) return;
|
|
342
|
+
const result = await db.updateTable("oidc_sessions").set({ clients: json([...current, clientId]) }).where("id", "=", id).where("clients", "=", row.clients).executeTakeFirst();
|
|
343
|
+
if (Number(result.numUpdatedRows) > 0) return;
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`session ${id} kept changing; its client list could not be updated`);
|
|
346
|
+
},
|
|
347
|
+
async destroy(id) {
|
|
348
|
+
await db.deleteFrom("oidc_sessions").where("id", "=", id).execute();
|
|
349
|
+
},
|
|
350
|
+
prune: { async prune(before, limit) {
|
|
351
|
+
const doomed = await db.selectFrom("oidc_sessions").select("id").where("expires_at", "<=", before.getTime()).limit(limit).execute();
|
|
352
|
+
if (doomed.length) await db.deleteFrom("oidc_sessions").where("id", "in", doomed.map((row) => row.id)).where("expires_at", "<=", before.getTime()).execute();
|
|
353
|
+
return doomed.length;
|
|
354
|
+
} }.prune
|
|
355
|
+
},
|
|
356
|
+
grants: {
|
|
357
|
+
async find(id) {
|
|
358
|
+
const row = await db.selectFrom("oidc_grants").selectAll().where("id", "=", id).executeTakeFirst();
|
|
359
|
+
return row ? toGrant(row) : void 0;
|
|
360
|
+
},
|
|
361
|
+
async findByAccountAndClient(accountId, clientId) {
|
|
362
|
+
const row = await db.selectFrom("oidc_grants").selectAll().where("account_id", "=", accountId).where("client_id", "=", clientId).executeTakeFirst();
|
|
363
|
+
return row ? toGrant(row) : void 0;
|
|
364
|
+
},
|
|
365
|
+
async listForAccount(accountId) {
|
|
366
|
+
return (await db.selectFrom("oidc_grants").selectAll().where("account_id", "=", accountId).execute()).map(toGrant);
|
|
367
|
+
},
|
|
368
|
+
async upsert(grant) {
|
|
369
|
+
const row = grantRow(grant);
|
|
370
|
+
await db.insertInto("oidc_grants").values(row).onConflict((conflict) => conflict.columns(["account_id", "client_id"]).doUpdateSet({
|
|
371
|
+
scopes: row.scopes,
|
|
372
|
+
claims: row.claims,
|
|
373
|
+
resources: row.resources,
|
|
374
|
+
expires_at: row.expires_at
|
|
375
|
+
})).execute();
|
|
376
|
+
},
|
|
377
|
+
async destroy(id) {
|
|
378
|
+
await db.deleteFrom("oidc_grants").where("id", "=", id).execute();
|
|
379
|
+
},
|
|
380
|
+
async destroyByClientId(clientId) {
|
|
381
|
+
await db.deleteFrom("oidc_grants").where("client_id", "=", clientId).execute();
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
accounts: options.accounts ?? noAccounts,
|
|
385
|
+
credentials: {
|
|
386
|
+
async find(accountId) {
|
|
387
|
+
const row = await db.selectFrom("oidc_credentials").selectAll().where("account_id", "=", accountId).executeTakeFirst();
|
|
388
|
+
return row ? toCredential(row) : void 0;
|
|
389
|
+
},
|
|
390
|
+
async findByUsername(username) {
|
|
391
|
+
const row = await db.selectFrom("oidc_credentials").selectAll().where("username", "=", username).executeTakeFirst();
|
|
392
|
+
return row ? toCredential(row) : void 0;
|
|
393
|
+
},
|
|
394
|
+
async create(credential) {
|
|
395
|
+
return await db.insertInto("oidc_credentials").values(credentialRow(credential)).onConflict((conflict) => conflict.doNothing()).returning("account_id").executeTakeFirst() !== void 0;
|
|
396
|
+
},
|
|
397
|
+
async upsert(credential) {
|
|
398
|
+
const row = credentialRow(credential);
|
|
399
|
+
await db.insertInto("oidc_credentials").values(row).onConflict((conflict) => conflict.column("account_id").doUpdateSet(row)).execute();
|
|
400
|
+
},
|
|
401
|
+
async destroy(accountId) {
|
|
402
|
+
await db.deleteFrom("oidc_credentials").where("account_id", "=", accountId).execute();
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
realms: {
|
|
406
|
+
async find(id) {
|
|
407
|
+
const row = await db.selectFrom("oidc_realms").selectAll().where("id", "=", id).executeTakeFirst();
|
|
408
|
+
return row ? toRealm(row) : void 0;
|
|
409
|
+
},
|
|
410
|
+
async list() {
|
|
411
|
+
return (await db.selectFrom("oidc_realms").selectAll().execute()).map(toRealm);
|
|
412
|
+
},
|
|
413
|
+
async create(realm) {
|
|
414
|
+
return await db.insertInto("oidc_realms").values(realmRow(realm)).onConflict((conflict) => conflict.doNothing()).returning("id").executeTakeFirst() !== void 0;
|
|
415
|
+
},
|
|
416
|
+
async upsert(realm) {
|
|
417
|
+
const row = realmRow(realm);
|
|
418
|
+
await db.insertInto("oidc_realms").values(row).onConflict((conflict) => conflict.column("id").doUpdateSet(row)).execute();
|
|
419
|
+
},
|
|
420
|
+
async destroy(id) {
|
|
421
|
+
await db.deleteFrom("oidc_realms").where("id", "=", id).execute();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
keys: options.keys,
|
|
425
|
+
replay: {
|
|
426
|
+
async claim(namespace, value, ttl) {
|
|
427
|
+
const expires_at = now() + ttl * 1e3;
|
|
428
|
+
return await db.insertInto("oidc_replay").values({
|
|
429
|
+
namespace,
|
|
430
|
+
value,
|
|
431
|
+
expires_at
|
|
432
|
+
}).onConflict((conflict) => conflict.columns(["namespace", "value"]).doUpdateSet({ expires_at }).where("oidc_replay.expires_at", "<=", now())).returning("value").executeTakeFirst() !== void 0;
|
|
433
|
+
},
|
|
434
|
+
prune: { async prune(before, limit) {
|
|
435
|
+
const doomed = await db.selectFrom("oidc_replay").select(["namespace", "value"]).where("expires_at", "<=", before.getTime()).limit(limit).execute();
|
|
436
|
+
for (const row of doomed) await db.deleteFrom("oidc_replay").where("namespace", "=", row.namespace).where("value", "=", row.value).where("expires_at", "<=", before.getTime()).execute();
|
|
437
|
+
return doomed.length;
|
|
438
|
+
} }.prune
|
|
439
|
+
},
|
|
440
|
+
identities: {
|
|
441
|
+
async find(provider, subject) {
|
|
442
|
+
const row = await db.selectFrom("oidc_identities").selectAll().where("provider", "=", provider).where("subject", "=", subject).executeTakeFirst();
|
|
443
|
+
return row ? toIdentity(row) : void 0;
|
|
444
|
+
},
|
|
445
|
+
async listForAccount(accountId) {
|
|
446
|
+
return (await db.selectFrom("oidc_identities").selectAll().where("account_id", "=", accountId).execute()).map(toIdentity);
|
|
447
|
+
},
|
|
448
|
+
async upsert(identity) {
|
|
449
|
+
const row = identityRow(identity);
|
|
450
|
+
await db.insertInto("oidc_identities").values(row).onConflict((conflict) => conflict.columns(["provider", "subject"]).doUpdateSet(row)).execute();
|
|
451
|
+
},
|
|
452
|
+
async destroy(provider, subject) {
|
|
453
|
+
await db.deleteFrom("oidc_identities").where("provider", "=", provider).where("subject", "=", subject).execute();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
};
|
|
458
|
+
export { POSTGRES_MIGRATIONS, SQLITE_MIGRATIONS, kyselyAdapter };
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oidcraft/kysely",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A Kysely storage adapter for @oidcraft/provider, on Postgres or SQLite.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Fl0r14n <florian.chis@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/Fl0r14n/oidcraft/tree/main/packages/kysely",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Fl0r14n/oidcraft",
|
|
11
|
+
"directory": "packages/kysely"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"oidc",
|
|
15
|
+
"openid",
|
|
16
|
+
"oauth2",
|
|
17
|
+
"authorization-server",
|
|
18
|
+
"oidcraft",
|
|
19
|
+
"storage",
|
|
20
|
+
"adapter",
|
|
21
|
+
"kysely"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"main": "./dist/index.mjs",
|
|
28
|
+
"types": "./dist/index.d.mts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"import": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown && bun run verify:entries",
|
|
38
|
+
"prepublishOnly": "bun run build",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"typecheck": "bun check",
|
|
41
|
+
"verify:entries": "bun run verify-entries.ts"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@oidcraft/adapter": "^0.2.0",
|
|
45
|
+
"kysely": ">=0.28"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@oidcraft/adapter": "^0.2.0",
|
|
49
|
+
"@oidcraft/sql": "0.0.0",
|
|
50
|
+
"@types/pg": "^8.23.1",
|
|
51
|
+
"kysely": "^0.29.8",
|
|
52
|
+
"kysely-bun-sqlite": "^0.4.0",
|
|
53
|
+
"pg": "^8.23.1"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=24.7"
|
|
60
|
+
}
|
|
61
|
+
}
|