@c9up/transit 0.1.5 → 0.1.7
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 +24 -1
- package/dist/configure.d.ts +6 -0
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +2 -14
- package/dist/configure.js.map +1 -1
- package/dist/drivers/LdapDirectory.d.ts.map +1 -1
- package/dist/drivers/LdapDirectory.js +3 -1
- package/dist/drivers/LdapDirectory.js.map +1 -1
- package/dist/drivers/SamlDriver.d.ts +17 -0
- package/dist/drivers/SamlDriver.d.ts.map +1 -1
- package/dist/drivers/SamlDriver.js +45 -4
- package/dist/drivers/SamlDriver.js.map +1 -1
- package/dist/ldap.d.ts.map +1 -1
- package/dist/ldap.js +35 -4
- package/dist/ldap.js.map +1 -1
- package/dist/oidc.d.ts.map +1 -1
- package/dist/oidc.js +7 -4
- package/dist/oidc.js.map +1 -1
- package/dist/quasar.d.ts +7 -2
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +8 -2
- package/dist/quasar.js.map +1 -1
- package/dist/replay.d.ts +3 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +11 -5
- package/dist/replay.js.map +1 -1
- package/dist/saml.d.ts +9 -1
- package/dist/saml.d.ts.map +1 -1
- package/dist/saml.js +1 -0
- package/dist/saml.js.map +1 -1
- package/dist/stubs.d.ts +14 -0
- package/dist/stubs.d.ts.map +1 -0
- package/dist/stubs.js +15 -0
- package/dist/stubs.js.map +1 -0
- package/dist/vendor/quasarConnection.d.ts +26 -3
- package/dist/vendor/quasarConnection.d.ts.map +1 -1
- package/dist/vendor/quasarConnection.js +44 -23
- package/dist/vendor/quasarConnection.js.map +1 -1
- package/package.json +4 -3
- package/src/configure.ts +9 -17
- package/src/drivers/LdapDirectory.ts +3 -1
- package/src/drivers/SamlDriver.ts +65 -4
- package/src/ldap.ts +39 -4
- package/src/oidc.ts +7 -4
- package/src/quasar.ts +9 -2
- package/src/replay.ts +17 -7
- package/src/saml.ts +10 -1
- package/src/stubs.ts +16 -0
- package/src/vendor/quasarConnection.ts +84 -22
- package/stubs/config/transit.stub +17 -0
package/src/replay.ts
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { quasarConnection } from "./quasar.js";
|
|
15
|
+
import { hasCommands } from "./vendor/quasarConnection.js";
|
|
15
16
|
|
|
16
17
|
export interface AssertionReplayStore {
|
|
17
18
|
/**
|
|
18
|
-
* Remember `assertionId` until `expiresAt` (epoch seconds)
|
|
19
|
+
* Remember `assertionId` until `expiresAt` (epoch seconds) — the
|
|
20
|
+
* `rememberUntil` of a validated assertion, which already counts the clock
|
|
21
|
+
* skew it was accepted with.
|
|
19
22
|
*
|
|
20
23
|
* Answers `true` when this is the first time, `false` when the assertion
|
|
21
24
|
* has already been used. Implementations must decide that atomically:
|
|
@@ -54,6 +57,10 @@ export interface ReplayRedisClient {
|
|
|
54
57
|
set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
function isReplayRedisClient(value: unknown): value is ReplayRedisClient {
|
|
61
|
+
return hasCommands(value, ["set"]);
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
export type ReplayRedisResolver =
|
|
58
65
|
| ReplayRedisClient
|
|
59
66
|
| (() => ReplayRedisClient | Promise<ReplayRedisClient>);
|
|
@@ -82,8 +89,9 @@ export class RedisAssertionReplayStore implements AssertionReplayStore {
|
|
|
82
89
|
|
|
83
90
|
async remember(assertionId: string, expiresAt: number): Promise<boolean> {
|
|
84
91
|
const client = await this.#client();
|
|
85
|
-
//
|
|
86
|
-
//
|
|
92
|
+
// `expiresAt` already covers the clock skew the validation allowed. The
|
|
93
|
+
// extra minute absorbs clock differences between the replica that
|
|
94
|
+
// stores the key and the one a replay lands on.
|
|
87
95
|
const ttl = Math.max(
|
|
88
96
|
1,
|
|
89
97
|
Math.ceil(expiresAt - Math.floor(Date.now() / 1000)) + 60,
|
|
@@ -121,15 +129,17 @@ export const replayStores = {
|
|
|
121
129
|
connection: ReplayRedisResolver | string;
|
|
122
130
|
prefix?: string;
|
|
123
131
|
}): () => AssertionReplayStore {
|
|
132
|
+
const { connection } = options;
|
|
124
133
|
const client: ReplayRedisResolver =
|
|
125
|
-
typeof
|
|
134
|
+
typeof connection === "string"
|
|
126
135
|
? () =>
|
|
127
|
-
quasarConnection
|
|
128
|
-
|
|
136
|
+
quasarConnection(
|
|
137
|
+
connection,
|
|
129
138
|
["set"],
|
|
130
139
|
"the assertion replay store",
|
|
140
|
+
isReplayRedisClient,
|
|
131
141
|
)
|
|
132
|
-
:
|
|
142
|
+
: connection;
|
|
133
143
|
return () =>
|
|
134
144
|
new RedisAssertionReplayStore(client, { prefix: options.prefix });
|
|
135
145
|
},
|
package/src/saml.ts
CHANGED
|
@@ -35,8 +35,16 @@ export interface SamlIdentity {
|
|
|
35
35
|
attributes: Record<string, string[]>;
|
|
36
36
|
/** The assertion's own id, for the replay check. */
|
|
37
37
|
assertionId: string;
|
|
38
|
-
/**
|
|
38
|
+
/** The `NotOnOrAfter` of the assertion's conditions, epoch seconds. */
|
|
39
39
|
notOnOrAfter: number;
|
|
40
|
+
/**
|
|
41
|
+
* Until when a replay record has to last, epoch seconds: the later of the
|
|
42
|
+
* two expiries the assertion carries (its conditions and its bearer
|
|
43
|
+
* confirmation) plus the clock skew this validation allowed. Before that
|
|
44
|
+
* instant the same assertion can still pass `validateAssertion`, so a
|
|
45
|
+
* record forgotten earlier lets a captured response sign in again.
|
|
46
|
+
*/
|
|
47
|
+
rememberUntil: number;
|
|
40
48
|
/** The provider's session, when it named one. */
|
|
41
49
|
sessionIndex: string | undefined;
|
|
42
50
|
}
|
|
@@ -232,6 +240,7 @@ export function validateAssertion(
|
|
|
232
240
|
attributes: readAttributes(assertion),
|
|
233
241
|
assertionId,
|
|
234
242
|
notOnOrAfter,
|
|
243
|
+
rememberUntil: Math.max(notOnOrAfter, confirmationExpiry) + skew,
|
|
235
244
|
sessionIndex: authn?.attributes.find((a) => a.local === "SessionIndex")
|
|
236
245
|
?.value,
|
|
237
246
|
};
|
package/src/stubs.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this package's stubs live.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, as upstream keeps it: a stub root is a path a package
|
|
5
|
+
* publishes, and a caller importing it should not have to import the
|
|
6
|
+
* configure hook to get it.
|
|
7
|
+
*
|
|
8
|
+
* `import.meta.dirname` rather than a URL — upstream reads it straight off
|
|
9
|
+
* `import.meta`, and deriving it from `import.meta.url` breaks wherever that
|
|
10
|
+
* is not a `file://` URL, which is what a test runner serving modules over
|
|
11
|
+
* HTTP hands you.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const stubsRoot = join(import.meta.dirname, "..", "stubs");
|
|
@@ -31,23 +31,62 @@ interface ConnectionSource {
|
|
|
31
31
|
* `"connection" in manager` is FALSE while reading it returns the function —
|
|
32
32
|
* gating on `in` rejected the real manager. And a module namespace under a
|
|
33
33
|
* test double RAISES for an export it does not have, rather than answering
|
|
34
|
-
* undefined, so a bare read fails on the mock.
|
|
35
|
-
*
|
|
34
|
+
* undefined, so a bare read fails on the mock.
|
|
35
|
+
*
|
|
36
|
+
* So the read is caught, but what it raised is kept: on quasar's accessor a
|
|
37
|
+
* throw is not an absence, it is the accessor saying its provider has not
|
|
38
|
+
* booted, and dropping that turned it into a wrong diagnosis.
|
|
36
39
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
type MemberRead = { value: unknown } | { threw: unknown };
|
|
41
|
+
|
|
42
|
+
function readMember(value: unknown, name: string): MemberRead {
|
|
43
|
+
if (typeof value !== "object" || value === null) return { value: undefined };
|
|
39
44
|
try {
|
|
40
|
-
return Reflect.get(value, name);
|
|
41
|
-
} catch {
|
|
42
|
-
return
|
|
45
|
+
return { value: Reflect.get(value, name) };
|
|
46
|
+
} catch (threw) {
|
|
47
|
+
return { threw };
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
function memberValue(read: MemberRead): unknown {
|
|
52
|
+
return "value" in read ? read.value : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
function isConnectionSource(value: unknown): value is ConnectionSource {
|
|
47
|
-
return typeof readMember(value, "connection") === "function";
|
|
56
|
+
return typeof memberValue(readMember(value, "connection")) === "function";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The commands among `commands` that `value` does not carry as functions.
|
|
61
|
+
*
|
|
62
|
+
* Reads go through the same catch as above, so a command whose read throws
|
|
63
|
+
* counts as missing.
|
|
64
|
+
*/
|
|
65
|
+
export function missingCommands(
|
|
66
|
+
value: unknown,
|
|
67
|
+
commands: readonly string[],
|
|
68
|
+
): string[] {
|
|
69
|
+
return commands.filter(
|
|
70
|
+
(command) => typeof memberValue(readMember(value, command)) !== "function",
|
|
71
|
+
);
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Whether `value` carries every one of `commands` as a function.
|
|
76
|
+
*
|
|
77
|
+
* The building block of the guard a caller passes as `isConnection`, over the
|
|
78
|
+
* same list it passes as `required` — which is what names the gap when the
|
|
79
|
+
* guard says no.
|
|
80
|
+
*/
|
|
81
|
+
export function hasCommands(
|
|
82
|
+
value: unknown,
|
|
83
|
+
commands: readonly string[],
|
|
84
|
+
): value is object {
|
|
85
|
+
if (typeof value !== "object" || value === null) return false;
|
|
86
|
+
return missingCommands(value, commands).length === 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface QuasarConnectionRequest<T> {
|
|
51
90
|
/** The package asking. It prefixes every message this can raise. */
|
|
52
91
|
pkg: string;
|
|
53
92
|
/**
|
|
@@ -65,8 +104,16 @@ export interface QuasarConnectionRequest {
|
|
|
65
104
|
) => Error;
|
|
66
105
|
/** Connection name, or `undefined` for quasar's default. */
|
|
67
106
|
name?: string;
|
|
68
|
-
/** The commands the caller will actually issue. */
|
|
107
|
+
/** The commands the caller will actually issue, named in the message. */
|
|
69
108
|
required: readonly string[];
|
|
109
|
+
/**
|
|
110
|
+
* The caller's check that the connection is the client it needs.
|
|
111
|
+
*
|
|
112
|
+
* Written by the caller over the commands it issues (`hasCommands` with
|
|
113
|
+
* `required` does it), because only the caller knows its client type. The
|
|
114
|
+
* connection is handed over only once this has said yes.
|
|
115
|
+
*/
|
|
116
|
+
isConnection: (value: unknown) => value is T;
|
|
70
117
|
/** What the caller does with them, for the message that names a gap. */
|
|
71
118
|
what: string;
|
|
72
119
|
/**
|
|
@@ -86,9 +133,10 @@ export interface QuasarConnectionRequest {
|
|
|
86
133
|
* command, far from the line that asked for it.
|
|
87
134
|
*/
|
|
88
135
|
export async function quasarConnection<T>(
|
|
89
|
-
request: QuasarConnectionRequest
|
|
136
|
+
request: QuasarConnectionRequest<T>,
|
|
90
137
|
): Promise<T> {
|
|
91
|
-
const { pkg, name, required, what, raise, alternative } =
|
|
138
|
+
const { pkg, name, required, what, raise, alternative, isConnection } =
|
|
139
|
+
request;
|
|
92
140
|
const specifier = "@c9up/quasar/services/main";
|
|
93
141
|
let loaded: unknown;
|
|
94
142
|
try {
|
|
@@ -102,9 +150,22 @@ export async function quasarConnection<T>(
|
|
|
102
150
|
);
|
|
103
151
|
}
|
|
104
152
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
153
|
+
// The namespace itself may be the manager (a test double); a throw there is
|
|
154
|
+
// the mock refusing an export it lacks, not an answer.
|
|
155
|
+
const direct = memberValue(readMember(loaded, "connection"));
|
|
156
|
+
const manager =
|
|
157
|
+
typeof direct === "function"
|
|
158
|
+
? loaded
|
|
159
|
+
: memberValue(readMember(Object(loaded), "default"));
|
|
160
|
+
const read = readMember(manager, "connection");
|
|
161
|
+
if ("threw" in read) {
|
|
162
|
+
throw raise(
|
|
163
|
+
"quasar-missing",
|
|
164
|
+
`[${pkg}] ${what} asks for the quasar connection "${name ?? "default"}", but @c9up/quasar's manager is not available yet: ` +
|
|
165
|
+
"its provider has not booted, or is not registered in this application's providers.",
|
|
166
|
+
read.threw,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
108
169
|
if (!isConnectionSource(manager)) {
|
|
109
170
|
throw raise(
|
|
110
171
|
"quasar-missing",
|
|
@@ -113,17 +174,18 @@ export async function quasarConnection<T>(
|
|
|
113
174
|
}
|
|
114
175
|
|
|
115
176
|
const connection = manager.connection(name);
|
|
116
|
-
const missing = required
|
|
117
|
-
(command) => typeof readMember(connection, command) !== "function",
|
|
118
|
-
);
|
|
177
|
+
const missing = missingCommands(connection, required);
|
|
119
178
|
if (missing.length > 0) {
|
|
120
179
|
throw raise(
|
|
121
180
|
"incomplete-connection",
|
|
122
181
|
`[${pkg}] the quasar connection${name ? ` '${name}'` : ""} is missing ${missing.join(", ")}, which ${what} issues`,
|
|
123
182
|
);
|
|
124
183
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
184
|
+
if (!isConnection(connection)) {
|
|
185
|
+
throw raise(
|
|
186
|
+
"incomplete-connection",
|
|
187
|
+
`[${pkg}] the quasar connection${name ? ` '${name}'` : ""} is not the client ${what} needs`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return connection;
|
|
129
191
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: config/transit.ts
|
|
3
|
+
---
|
|
4
|
+
import { defineConfig, socials } from '@c9up/transit'
|
|
5
|
+
import env from '#start/env'
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
// The key is yours: it is what `transit.begin(name)` asks for.
|
|
9
|
+
google: socials.google({
|
|
10
|
+
clientId: env.get('GOOGLE_CLIENT_ID', ''),
|
|
11
|
+
clientSecret: env.get('GOOGLE_CLIENT_SECRET', ''),
|
|
12
|
+
callbackUrl: 'http://localhost:3333/auth/google/callback',
|
|
13
|
+
}),
|
|
14
|
+
|
|
15
|
+
// An OpenID Connect provider needs only its issuer:
|
|
16
|
+
// work: oidc({ issuer: env.get('OIDC_ISSUER'), clientId, clientSecret, callbackUrl }),
|
|
17
|
+
})
|