@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 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
- Credentials are 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.
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
- ## Two traps these adapters absorb
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
- clientSecret: client.client_secret ?? null,
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, "clientSecret", void 0);
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
- * Client secret for confidential clients, `null` for public clients
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
- clientSecret: string | null;
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
- * Client secret for confidential clients, `null` for public clients
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
- clientSecret: string | null;
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
- clientSecret: client.client_secret ?? null,
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, "clientSecret", void 0);
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.0",
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.0",
42
+ "@ttoss/auth-core": "^0.13.1",
43
43
  "@ttoss/postgresdb": "^0.11.0"
44
44
  },
45
45
  "publishConfig": {