@ttoss/auth-postgresdb 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/index.cjs +27 -6
- package/dist/index.d.cts +15 -4
- package/dist/index.d.mts +15 -4
- package/dist/index.mjs +28 -7
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -57,12 +57,16 @@ Each store is also exported on its own (`createClientStore`, `createAuthCodeStor
|
|
|
57
57
|
| `OAuthConsent` | `oauth_consents` | `code_challenge` |
|
|
58
58
|
| `OAuthRefreshToken` | `oauth_refresh_tokens` | `token_hash` |
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Every credential — authorization codes (`code_hash`), refresh tokens (`token_hash`), and client secrets (`client_secret_hash`) — is stored by SHA-256 hash, never in plaintext, so a database dump yields nothing replayable. `OAuthClient` keeps the registered RFC 7591 fields in their own columns and any additional submitted metadata in a `metadata` JSONB column, so a registration document round-trips unchanged.
|
|
61
61
|
|
|
62
|
-
##
|
|
62
|
+
## Three traps these adapters absorb
|
|
63
63
|
|
|
64
64
|
**Authorization codes are hashed at rest.** `AuthCodeStore.get` is handed the plaintext code, but a code travels through a browser redirect and a client's URL bar. The engine never compares the returned `code` against anything — it reads only `clientId`, `redirectUri`, `codeChallenge`, `scopes`, `subject`, and `expiresAt` — so the adapter hashes to find the row and echoes the presented value back.
|
|
65
65
|
|
|
66
|
+
**Client secrets are hashed at rest too, which takes a different mechanism.** A secret _is_ compared, so the adapter cannot simply hash the row away and echo the presented value back. Instead `clientStore` implements `ClientStore.verifyClientSecret`: the engine hands over the presented secret and the store compares it against the stored hash in constant time, so the raw value never has to be recoverable. Consequently `get` omits `client_secret` from the document it returns — nothing in the engine needs it, since the registration response echoes the secret from the document it just generated rather than from a read.
|
|
67
|
+
|
|
68
|
+
Implementing `verifyClientSecret` is what makes hashing possible at all. A store that omits it falls back to the engine comparing the `client_secret` returned by `get`, which forces the store to keep the secret recoverable — plaintext, or encrypted with a key the app has to manage.
|
|
69
|
+
|
|
66
70
|
**A live refresh token reports no `consumedAt` at all.** `createRefreshRotation` treats `stored.consumedAt !== undefined` as reuse. A nullable timestamp column reads back as `null`, which is `!== undefined`, so a naive adapter makes every live token look consumed: the first refresh is treated as a replay and revokes the owner's entire token set — "refresh works once, then the client must re-authorize forever". The adapter omits the key instead of setting it to `null`.
|
|
67
71
|
|
|
68
72
|
## Consent
|
package/dist/index.cjs
CHANGED
|
@@ -65,6 +65,9 @@ var createAuthCodeStore = ({
|
|
|
65
65
|
/**
|
|
66
66
|
* Registration fields that have their own column. Everything else a client
|
|
67
67
|
* submits goes to `metadata`, so the document round-trips unchanged.
|
|
68
|
+
*
|
|
69
|
+
* `client_secret` is listed here even though no column holds it verbatim: it
|
|
70
|
+
* must never fall through to the `metadata` JSONB, which would defeat hashing.
|
|
68
71
|
*/
|
|
69
72
|
var COLUMN_FIELDS = ["client_id", "client_secret", "client_name", "redirect_uris", "grant_types", "response_types", "token_endpoint_auth_method", "scope", "client_id_issued_at"];
|
|
70
73
|
var toRecord = row => {
|
|
@@ -72,9 +75,6 @@ var toRecord = row => {
|
|
|
72
75
|
...row.metadata,
|
|
73
76
|
client_id: row.clientId,
|
|
74
77
|
redirect_uris: row.redirectUris,
|
|
75
|
-
...(row.clientSecret !== null && {
|
|
76
|
-
client_secret: row.clientSecret
|
|
77
|
-
}),
|
|
78
78
|
...(row.clientName !== null && {
|
|
79
79
|
client_name: row.clientName
|
|
80
80
|
}),
|
|
@@ -101,7 +101,14 @@ var toMetadata = client => {
|
|
|
101
101
|
return metadata;
|
|
102
102
|
};
|
|
103
103
|
/**
|
|
104
|
-
* Creates a {@link ClientStore} backed by the `oauth_clients` table
|
|
104
|
+
* Creates a {@link ClientStore} backed by the `oauth_clients` table, storing
|
|
105
|
+
* client secrets by SHA-256 hash.
|
|
106
|
+
*
|
|
107
|
+
* Because the secret is not recoverable, `get` omits `client_secret` and client
|
|
108
|
+
* authentication goes through `verifyClientSecret`, which compares the
|
|
109
|
+
* presented value against the stored hash. The engine only ever needs that
|
|
110
|
+
* comparison — the registration response echoes the secret from the document it
|
|
111
|
+
* just generated, never from a read — so nothing is lost by not keeping it.
|
|
105
112
|
*
|
|
106
113
|
* `register` upserts, so re-registering an existing `client_id` replaces the
|
|
107
114
|
* stored document rather than failing on the primary key.
|
|
@@ -114,10 +121,24 @@ var createClientStore = ({
|
|
|
114
121
|
const row = await model.findByPk(clientId);
|
|
115
122
|
return row ? toRecord(row) : void 0;
|
|
116
123
|
},
|
|
124
|
+
verifyClientSecret: async ({
|
|
125
|
+
clientId,
|
|
126
|
+
clientSecret
|
|
127
|
+
}) => {
|
|
128
|
+
const row = await model.findByPk(clientId);
|
|
129
|
+
if (!row) return false;
|
|
130
|
+
if (row.clientSecretHash === null) return true;
|
|
131
|
+
return (0, _ttoss_auth_core.verifyClientSecret)({
|
|
132
|
+
clientSecret,
|
|
133
|
+
clientSecretHash: row.clientSecretHash
|
|
134
|
+
});
|
|
135
|
+
},
|
|
117
136
|
register: async client => {
|
|
118
137
|
await model.upsert({
|
|
119
138
|
clientId: client.client_id,
|
|
120
|
-
|
|
139
|
+
clientSecretHash: client.client_secret ? (0, _ttoss_auth_core.hashClientSecret)({
|
|
140
|
+
clientSecret: client.client_secret
|
|
141
|
+
}) : null,
|
|
121
142
|
clientName: client.client_name ?? null,
|
|
122
143
|
redirectUris: client.redirect_uris,
|
|
123
144
|
grantTypes: client.grant_types ?? null,
|
|
@@ -349,7 +370,7 @@ __decorate([_ttoss_postgresdb.PrimaryKey, (0, _ttoss_postgresdb.Column)({
|
|
|
349
370
|
__decorate([(0, _ttoss_postgresdb.Column)({
|
|
350
371
|
type: _ttoss_postgresdb.DataType.STRING,
|
|
351
372
|
allowNull: true
|
|
352
|
-
}), __decorateMetadata("design:type", Object)], OAuthClient.prototype, "
|
|
373
|
+
}), __decorateMetadata("design:type", Object)], OAuthClient.prototype, "clientSecretHash", void 0);
|
|
353
374
|
__decorate([(0, _ttoss_postgresdb.Column)({
|
|
354
375
|
type: _ttoss_postgresdb.DataType.STRING,
|
|
355
376
|
allowNull: true
|
package/dist/index.d.cts
CHANGED
|
@@ -39,10 +39,14 @@ declare class OAuthClient extends Model {
|
|
|
39
39
|
/** The `client_id` issued by the authorization server. */
|
|
40
40
|
clientId: string;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
* (`token_endpoint_auth_method: 'none'`).
|
|
42
|
+
* SHA-256 hash (hex) of the client secret for confidential clients, `null`
|
|
43
|
+
* for public clients (`token_endpoint_auth_method: 'none'`).
|
|
44
|
+
*
|
|
45
|
+
* The secret itself is never stored: the token endpoint only ever compares a
|
|
46
|
+
* presented value, so a hash is enough and a database dump yields nothing
|
|
47
|
+
* replayable.
|
|
44
48
|
*/
|
|
45
|
-
|
|
49
|
+
clientSecretHash: string | null;
|
|
46
50
|
/** Human-readable client name shown on consent screens. */
|
|
47
51
|
clientName: string | null;
|
|
48
52
|
/** Exact redirect URIs registered for this client. */
|
|
@@ -218,7 +222,14 @@ declare const createAuthCodeStore: ({
|
|
|
218
222
|
//#endregion
|
|
219
223
|
//#region src/stores/createClientStore.d.ts
|
|
220
224
|
/**
|
|
221
|
-
* Creates a {@link ClientStore} backed by the `oauth_clients` table
|
|
225
|
+
* Creates a {@link ClientStore} backed by the `oauth_clients` table, storing
|
|
226
|
+
* client secrets by SHA-256 hash.
|
|
227
|
+
*
|
|
228
|
+
* Because the secret is not recoverable, `get` omits `client_secret` and client
|
|
229
|
+
* authentication goes through `verifyClientSecret`, which compares the
|
|
230
|
+
* presented value against the stored hash. The engine only ever needs that
|
|
231
|
+
* comparison — the registration response echoes the secret from the document it
|
|
232
|
+
* just generated, never from a read — so nothing is lost by not keeping it.
|
|
222
233
|
*
|
|
223
234
|
* `register` upserts, so re-registering an existing `client_id` replaces the
|
|
224
235
|
* stored document rather than failing on the primary key.
|
package/dist/index.d.mts
CHANGED
|
@@ -39,10 +39,14 @@ declare class OAuthClient extends Model {
|
|
|
39
39
|
/** The `client_id` issued by the authorization server. */
|
|
40
40
|
clientId: string;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
* (`token_endpoint_auth_method: 'none'`).
|
|
42
|
+
* SHA-256 hash (hex) of the client secret for confidential clients, `null`
|
|
43
|
+
* for public clients (`token_endpoint_auth_method: 'none'`).
|
|
44
|
+
*
|
|
45
|
+
* The secret itself is never stored: the token endpoint only ever compares a
|
|
46
|
+
* presented value, so a hash is enough and a database dump yields nothing
|
|
47
|
+
* replayable.
|
|
44
48
|
*/
|
|
45
|
-
|
|
49
|
+
clientSecretHash: string | null;
|
|
46
50
|
/** Human-readable client name shown on consent screens. */
|
|
47
51
|
clientName: string | null;
|
|
48
52
|
/** Exact redirect URIs registered for this client. */
|
|
@@ -218,7 +222,14 @@ declare const createAuthCodeStore: ({
|
|
|
218
222
|
//#endregion
|
|
219
223
|
//#region src/stores/createClientStore.d.ts
|
|
220
224
|
/**
|
|
221
|
-
* Creates a {@link ClientStore} backed by the `oauth_clients` table
|
|
225
|
+
* Creates a {@link ClientStore} backed by the `oauth_clients` table, storing
|
|
226
|
+
* client secrets by SHA-256 hash.
|
|
227
|
+
*
|
|
228
|
+
* Because the secret is not recoverable, `get` omits `client_secret` and client
|
|
229
|
+
* authentication goes through `verifyClientSecret`, which compares the
|
|
230
|
+
* presented value against the stored hash. The engine only ever needs that
|
|
231
|
+
* comparison — the registration response echoes the secret from the document it
|
|
232
|
+
* just generated, never from a read — so nothing is lost by not keeping it.
|
|
222
233
|
*
|
|
223
234
|
* `register` upserts, so re-registering an existing `client_id` replaces the
|
|
224
235
|
* stored document rather than failing on the primary key.
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Powered by @ttoss/config. https://ttoss.dev/docs/modules/packages/config/ */
|
|
2
|
-
import { hashAuthorizationCode } from "@ttoss/auth-core";
|
|
2
|
+
import { hashAuthorizationCode, hashClientSecret, verifyClientSecret } from "@ttoss/auth-core";
|
|
3
3
|
import { Column, DataType, Model, PrimaryKey, Table } from "@ttoss/postgresdb";
|
|
4
4
|
|
|
5
5
|
//#region src/stores/createAuthCodeStore.ts
|
|
@@ -62,6 +62,9 @@ var createAuthCodeStore = ({
|
|
|
62
62
|
/**
|
|
63
63
|
* Registration fields that have their own column. Everything else a client
|
|
64
64
|
* submits goes to `metadata`, so the document round-trips unchanged.
|
|
65
|
+
*
|
|
66
|
+
* `client_secret` is listed here even though no column holds it verbatim: it
|
|
67
|
+
* must never fall through to the `metadata` JSONB, which would defeat hashing.
|
|
65
68
|
*/
|
|
66
69
|
var COLUMN_FIELDS = ["client_id", "client_secret", "client_name", "redirect_uris", "grant_types", "response_types", "token_endpoint_auth_method", "scope", "client_id_issued_at"];
|
|
67
70
|
var toRecord = row => {
|
|
@@ -69,9 +72,6 @@ var toRecord = row => {
|
|
|
69
72
|
...row.metadata,
|
|
70
73
|
client_id: row.clientId,
|
|
71
74
|
redirect_uris: row.redirectUris,
|
|
72
|
-
...(row.clientSecret !== null && {
|
|
73
|
-
client_secret: row.clientSecret
|
|
74
|
-
}),
|
|
75
75
|
...(row.clientName !== null && {
|
|
76
76
|
client_name: row.clientName
|
|
77
77
|
}),
|
|
@@ -98,7 +98,14 @@ var toMetadata = client => {
|
|
|
98
98
|
return metadata;
|
|
99
99
|
};
|
|
100
100
|
/**
|
|
101
|
-
* Creates a {@link ClientStore} backed by the `oauth_clients` table
|
|
101
|
+
* Creates a {@link ClientStore} backed by the `oauth_clients` table, storing
|
|
102
|
+
* client secrets by SHA-256 hash.
|
|
103
|
+
*
|
|
104
|
+
* Because the secret is not recoverable, `get` omits `client_secret` and client
|
|
105
|
+
* authentication goes through `verifyClientSecret`, which compares the
|
|
106
|
+
* presented value against the stored hash. The engine only ever needs that
|
|
107
|
+
* comparison — the registration response echoes the secret from the document it
|
|
108
|
+
* just generated, never from a read — so nothing is lost by not keeping it.
|
|
102
109
|
*
|
|
103
110
|
* `register` upserts, so re-registering an existing `client_id` replaces the
|
|
104
111
|
* stored document rather than failing on the primary key.
|
|
@@ -111,10 +118,24 @@ var createClientStore = ({
|
|
|
111
118
|
const row = await model.findByPk(clientId);
|
|
112
119
|
return row ? toRecord(row) : void 0;
|
|
113
120
|
},
|
|
121
|
+
verifyClientSecret: async ({
|
|
122
|
+
clientId,
|
|
123
|
+
clientSecret
|
|
124
|
+
}) => {
|
|
125
|
+
const row = await model.findByPk(clientId);
|
|
126
|
+
if (!row) return false;
|
|
127
|
+
if (row.clientSecretHash === null) return true;
|
|
128
|
+
return verifyClientSecret({
|
|
129
|
+
clientSecret,
|
|
130
|
+
clientSecretHash: row.clientSecretHash
|
|
131
|
+
});
|
|
132
|
+
},
|
|
114
133
|
register: async client => {
|
|
115
134
|
await model.upsert({
|
|
116
135
|
clientId: client.client_id,
|
|
117
|
-
|
|
136
|
+
clientSecretHash: client.client_secret ? hashClientSecret({
|
|
137
|
+
clientSecret: client.client_secret
|
|
138
|
+
}) : null,
|
|
118
139
|
clientName: client.client_name ?? null,
|
|
119
140
|
redirectUris: client.redirect_uris,
|
|
120
141
|
grantTypes: client.grant_types ?? null,
|
|
@@ -346,7 +367,7 @@ __decorate([PrimaryKey, Column({
|
|
|
346
367
|
__decorate([Column({
|
|
347
368
|
type: DataType.STRING,
|
|
348
369
|
allowNull: true
|
|
349
|
-
}), __decorateMetadata("design:type", Object)], OAuthClient.prototype, "
|
|
370
|
+
}), __decorateMetadata("design:type", Object)], OAuthClient.prototype, "clientSecretHash", void 0);
|
|
350
371
|
__decorate([Column({
|
|
351
372
|
type: DataType.STRING,
|
|
352
373
|
allowNull: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttoss/auth-postgresdb",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Durable Postgres OAuth stores and models for @ttoss/auth-core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"auth",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"jest": "^30.4.2",
|
|
36
36
|
"tsdown": "^0.22.2",
|
|
37
|
+
"@ttoss/auth-core": "^0.13.1",
|
|
37
38
|
"@ttoss/config": "^1.37.17",
|
|
38
|
-
"@ttoss/postgresdb": "^0.11.0"
|
|
39
|
-
"@ttoss/auth-core": "^0.13.0"
|
|
39
|
+
"@ttoss/postgresdb": "^0.11.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@ttoss/auth-core": "^0.13.
|
|
42
|
+
"@ttoss/auth-core": "^0.13.1",
|
|
43
43
|
"@ttoss/postgresdb": "^0.11.0"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|