@camstack/server 1.2.603 → 1.2.605
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/dist/api/auth-whoami.js +28 -43
- package/dist/api/core/auth-admin-tokens.procedures.js +42 -24
- package/dist/api/core/auth-device-cert.procedures.js +1 -1
- package/dist/api/core/auth-reauth.procedures.js +1 -1
- package/dist/api/core/auth-revoke-credentials.procedures.js +54 -7
- package/dist/api/core/auth-sessions.procedures.js +1 -1
- package/dist/api/core/auth-tokens.procedures.js +250 -0
- package/dist/api/core/auth.router.js +95 -12
- package/dist/api/core/live-events.router.js +1 -1
- package/dist/api/trpc/principal-authorization.js +82 -0
- package/dist/api/trpc/principal-visibility.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +10 -9
- package/dist/api/trpc/trpc.context.js +82 -256
- package/dist/api/trpc/trpc.middleware.js +42 -83
- package/dist/api/trpc/trpc.router.js +1 -0
- package/dist/api/upload-auth.js +29 -52
- package/dist/auth/addon-route-auth.js +44 -0
- package/dist/auth/addon-route-share-gate.js +18 -16
- package/dist/auth/credential-verifier.js +277 -0
- package/dist/auth/scope-escalation.js +58 -0
- package/dist/core/addon/addon-registry.service.js +17 -0
- package/dist/core/auth/credential-token.service.js +250 -0
- package/dist/core/auth/record-kinds.js +31 -0
- package/dist/main.js +51 -97
- package/package.json +14 -14
- package/dist/auth/addon-route-jwt-gate.js +0 -59
package/dist/api/auth-whoami.js
CHANGED
|
@@ -1,55 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveWhoami = resolveWhoami;
|
|
3
4
|
exports.registerAuthWhoamiRoute = registerAuthWhoamiRoute;
|
|
5
|
+
const trpc_context_js_1 = require("./trpc/trpc.context.js");
|
|
6
|
+
/** The answer for `bearer`, or `null` when it authenticates nobody. */
|
|
7
|
+
async function resolveWhoami(deps, bearer) {
|
|
8
|
+
const principal = await (0, trpc_context_js_1.hubCredentialVerifier)(deps).verify(bearer);
|
|
9
|
+
if (principal === null)
|
|
10
|
+
return null;
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
kind: principal.userDelegated ? 'jwt' : 'scoped',
|
|
14
|
+
userId: principal.subjectId,
|
|
15
|
+
username: principal.username,
|
|
16
|
+
isAdmin: principal.isAdmin,
|
|
17
|
+
scopes: principal.scopes,
|
|
18
|
+
credentialId: principal.credentialId,
|
|
19
|
+
credential: principal.auditTag,
|
|
20
|
+
legacy: principal.legacy,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
4
23
|
async function registerAuthWhoamiRoute(fastify, authService, addonRegistry) {
|
|
5
24
|
fastify.get('/api/auth/whoami', async (request, reply) => {
|
|
6
25
|
const authHeader = request.headers.authorization;
|
|
7
26
|
if (!authHeader) {
|
|
8
|
-
|
|
27
|
+
const refused = { ok: false, error: 'No Authorization header' };
|
|
28
|
+
return reply.status(401).send(refused);
|
|
9
29
|
}
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
ok: true,
|
|
16
|
-
kind: 'jwt',
|
|
17
|
-
userId: payload.userId ?? 'unknown',
|
|
18
|
-
username: payload.username ?? 'unknown',
|
|
19
|
-
isAdmin: payload.isAdmin,
|
|
30
|
+
const answer = await resolveWhoami({ authService, addonRegistry }, authHeader.replace('Bearer ', ''));
|
|
31
|
+
if (answer === null) {
|
|
32
|
+
const refused = {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: 'Token not recognised (revoked, expired, or never issued)',
|
|
20
35
|
};
|
|
21
|
-
return reply.send(
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Not a JWT — try scoped token via cap registry.
|
|
25
|
-
}
|
|
26
|
-
try {
|
|
27
|
-
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
28
|
-
const userMgmt = capRegistry.getSingleton('user-management');
|
|
29
|
-
if (!userMgmt) {
|
|
30
|
-
return reply.status(503).send({ ok: false, error: 'user-management cap not mounted' });
|
|
31
|
-
}
|
|
32
|
-
const record = await userMgmt.validateScopedToken({ token });
|
|
33
|
-
if (!record) {
|
|
34
|
-
return reply
|
|
35
|
-
.status(401)
|
|
36
|
-
.send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' });
|
|
37
|
-
}
|
|
38
|
-
const ok = {
|
|
39
|
-
ok: true,
|
|
40
|
-
kind: 'scoped',
|
|
41
|
-
userId: record.userId,
|
|
42
|
-
username: record.userId,
|
|
43
|
-
tokenPrefix: record.tokenPrefix,
|
|
44
|
-
scopes: record.scopes,
|
|
45
|
-
expiresAt: record.expiresAt ?? null,
|
|
46
|
-
createdAt: record.createdAt,
|
|
47
|
-
};
|
|
48
|
-
return reply.send(ok);
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
-
return reply.status(500).send({ ok: false, error: `Validation failed: ${msg}` });
|
|
36
|
+
return reply.status(401).send(refused);
|
|
53
37
|
}
|
|
38
|
+
return reply.send(answer);
|
|
54
39
|
});
|
|
55
40
|
}
|
|
@@ -15,6 +15,8 @@ const zod_1 = require("zod");
|
|
|
15
15
|
const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
|
|
16
16
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
17
17
|
const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
|
|
18
|
+
const auth_tokens_procedures_js_1 = require("./auth-tokens.procedures.js");
|
|
19
|
+
const types_1 = require("@camstack/types");
|
|
18
20
|
const DAY_MS = 86_400_000;
|
|
19
21
|
exports.ADMIN_TOKEN_DEFAULT_DAYS = 7;
|
|
20
22
|
exports.ADMIN_TOKEN_MAX_DAYS = system_1.ADMIN_TOKEN_MAX_LIFETIME_MS / DAY_MS;
|
|
@@ -27,7 +29,23 @@ exports.AdminTokenSchema = zod_1.z.object({
|
|
|
27
29
|
expiresAt: zod_1.z.number(),
|
|
28
30
|
lastUsedAt: zod_1.z.number().nullable(),
|
|
29
31
|
revokedAt: zod_1.z.number().nullable(),
|
|
32
|
+
/** A pre-D892 `csa_` token: valid until it expires, never minted again. */
|
|
33
|
+
legacy: zod_1.z.boolean().optional(),
|
|
30
34
|
});
|
|
35
|
+
/** A unified `'*'` token, in the admin-token listing's shape. */
|
|
36
|
+
function adminSummaryOf(record, expiresAt) {
|
|
37
|
+
return {
|
|
38
|
+
id: record.id,
|
|
39
|
+
createdBy: record.createdBy,
|
|
40
|
+
name: record.label,
|
|
41
|
+
tokenPrefix: `token:${record.id.slice(0, 8)}`,
|
|
42
|
+
createdAt: record.createdAt,
|
|
43
|
+
expiresAt,
|
|
44
|
+
lastUsedAt: record.lastUsedAt,
|
|
45
|
+
revokedAt: record.revokedAt,
|
|
46
|
+
legacy: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
31
49
|
exports.AuthAuditRecordSchema = zod_1.z.object({
|
|
32
50
|
id: zod_1.z.string(),
|
|
33
51
|
at: zod_1.z.number(),
|
|
@@ -67,47 +85,47 @@ function createAdminTokenProcedures(deps) {
|
|
|
67
85
|
}))
|
|
68
86
|
.output(zod_1.z.object({ token: zod_1.z.string(), record: exports.AdminTokenSchema }))
|
|
69
87
|
.mutation(async ({ ctx, input }) => {
|
|
70
|
-
if (ctx.user.
|
|
71
|
-
ctx.user.shareView !== undefined ||
|
|
72
|
-
ctx.user.credential !== undefined) {
|
|
88
|
+
if (ctx.user.interactive !== true) {
|
|
73
89
|
throw new server_1.TRPCError({
|
|
74
90
|
code: 'FORBIDDEN',
|
|
75
91
|
message: 'An admin token can only be created from a signed-in admin session',
|
|
76
92
|
});
|
|
77
93
|
}
|
|
78
|
-
(
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
86
|
-
await internal.audit.record({
|
|
87
|
-
action: 'admin-token-created',
|
|
88
|
-
userId: ctx.user.id,
|
|
89
|
-
...(ip !== null ? { ip } : {}),
|
|
90
|
-
detail: {
|
|
91
|
-
tokenId: created.record.id,
|
|
92
|
-
name: created.record.name,
|
|
93
|
-
prefix: created.record.tokenPrefix,
|
|
94
|
-
expiresAt: created.record.expiresAt,
|
|
95
|
-
},
|
|
94
|
+
// A thin wrapper for one release (D892): the token is a unified one
|
|
95
|
+
// holding `'*'`, minted — re-authentication included — by the one mint.
|
|
96
|
+
const minted = await (0, auth_tokens_procedures_js_1.mintFor)(deps, ctx, {
|
|
97
|
+
scopes: [types_1.ADMIN_SCOPE],
|
|
98
|
+
expiresIn: (0, auth_tokens_procedures_js_1.daysToSeconds)(input.expiresInDays),
|
|
99
|
+
label: input.name,
|
|
100
|
+
...(input.reauthToken !== undefined ? { reauthToken: input.reauthToken } : {}),
|
|
96
101
|
});
|
|
97
|
-
|
|
102
|
+
const expiresAt = minted.record.expiresAt ?? Date.now() + input.expiresInDays * DAY_MS;
|
|
103
|
+
return { token: minted.token, record: adminSummaryOf(minted.record, expiresAt) };
|
|
98
104
|
}),
|
|
99
105
|
/** ADMIN — every admin token not yet reaped (no secrets). */
|
|
100
106
|
listAdminTokens: trpc_middleware_js_1.adminProcedure
|
|
101
107
|
.input(zod_1.z.void())
|
|
102
108
|
.output(zod_1.z.array(exports.AdminTokenSchema))
|
|
103
|
-
.query(async () =>
|
|
109
|
+
.query(async () => {
|
|
110
|
+
const unified = (deps.credentialTokens === null ? [] : await deps.credentialTokens.list())
|
|
111
|
+
.filter((t) => (0, types_1.scopesGrantAdmin)(t.scopes) && t.expiresAt !== null)
|
|
112
|
+
.map((t) => adminSummaryOf(t, t.expiresAt ?? 0));
|
|
113
|
+
const legacy = (await requireInternal(deps.registry).adminTokens.list()).map((t) => ({
|
|
114
|
+
...t,
|
|
115
|
+
legacy: true,
|
|
116
|
+
}));
|
|
117
|
+
return [...unified, ...legacy];
|
|
118
|
+
}),
|
|
104
119
|
/** ADMIN — revoke one admin token; it stops working at once. */
|
|
105
120
|
revokeAdminToken: trpc_middleware_js_1.adminProcedure
|
|
106
121
|
.input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
|
|
107
122
|
.output(zod_1.z.object({ success: zod_1.z.boolean() }))
|
|
108
123
|
.mutation(async ({ ctx, input }) => {
|
|
109
124
|
const internal = requireInternal(deps.registry);
|
|
110
|
-
const
|
|
125
|
+
const unified = deps.credentialTokens === null ? null : await deps.credentialTokens.get(input.id);
|
|
126
|
+
const success = unified !== null && deps.credentialTokens !== null
|
|
127
|
+
? await deps.credentialTokens.revoke(input.id)
|
|
128
|
+
: await internal.adminTokens.revoke(input.id);
|
|
111
129
|
if (success) {
|
|
112
130
|
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
113
131
|
await internal.audit.record({
|
|
@@ -110,7 +110,7 @@ function createDeviceCertProcedures(deps) {
|
|
|
110
110
|
}
|
|
111
111
|
};
|
|
112
112
|
const assertPerson = (user) => {
|
|
113
|
-
if (user.
|
|
113
|
+
if (user.userDelegated !== true) {
|
|
114
114
|
throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
|
|
115
115
|
}
|
|
116
116
|
};
|
|
@@ -31,7 +31,7 @@ function isPasswordChecker(value) {
|
|
|
31
31
|
}
|
|
32
32
|
/** Real user sessions only: a scoped or share token cannot prove a password. */
|
|
33
33
|
function assertPerson(user) {
|
|
34
|
-
if (user.
|
|
34
|
+
if (user.userDelegated !== true) {
|
|
35
35
|
throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -10,6 +10,7 @@ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
|
10
10
|
exports.REVOKE_CREDENTIALS_MAX = 500;
|
|
11
11
|
/** What a bulk revoke can target. Each has a per-row revoke on the server. */
|
|
12
12
|
exports.RevocableCredentialKindSchema = zod_1.z.enum([
|
|
13
|
+
'token',
|
|
13
14
|
'share-link',
|
|
14
15
|
'admin-token',
|
|
15
16
|
'login-session',
|
|
@@ -19,7 +20,13 @@ exports.RevocableCredentialKindSchema = zod_1.z.enum([
|
|
|
19
20
|
exports.RevokeSkipReasonSchema = zod_1.z.enum(['current-session', 'not-active']);
|
|
20
21
|
const RevokeCredentialsInputSchema = zod_1.z
|
|
21
22
|
.object({
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Deprecated (D892): revocation is by CREDENTIAL ID. Omitted, each id is
|
|
25
|
+
* resolved against every store — unified tokens, login sessions, legacy
|
|
26
|
+
* admin / share / scoped tokens, account links. Kept one release for the
|
|
27
|
+
* clients that still send it; it then restricts the lookup to that store.
|
|
28
|
+
*/
|
|
29
|
+
kind: exports.RevocableCredentialKindSchema.optional(),
|
|
23
30
|
ids: zod_1.z.array(zod_1.z.string().min(1)).min(1).max(exports.REVOKE_CREDENTIALS_MAX),
|
|
24
31
|
/** The count the operator confirmed. Must equal `ids.length`. */
|
|
25
32
|
confirmCount: zod_1.z.number().int().min(1),
|
|
@@ -35,10 +42,7 @@ const RevokeCredentialsOutputSchema = zod_1.z.object({
|
|
|
35
42
|
/** Keep the audit row bounded; the ids beyond this are counted, not listed. */
|
|
36
43
|
const AUDIT_IDS_MAX_CHARS = 4000;
|
|
37
44
|
function assertSignedInAdminSession(user) {
|
|
38
|
-
if (user.
|
|
39
|
-
user.shareView !== undefined ||
|
|
40
|
-
user.credential !== undefined ||
|
|
41
|
-
user.adminTokenId !== undefined) {
|
|
45
|
+
if (user.interactive !== true) {
|
|
42
46
|
throw new server_1.TRPCError({
|
|
43
47
|
code: 'FORBIDDEN',
|
|
44
48
|
message: 'Revoking credentials in bulk needs a signed-in admin session',
|
|
@@ -57,6 +61,13 @@ function requireInternal(registry) {
|
|
|
57
61
|
}
|
|
58
62
|
/** The per-row revoke for `kind`, bound to the caller. `null` = revoked. */
|
|
59
63
|
function revokerFor(kind, caller, deps) {
|
|
64
|
+
if (kind === 'token') {
|
|
65
|
+
const service = deps.credentialTokens ?? null;
|
|
66
|
+
if (service === null) {
|
|
67
|
+
throw new server_1.TRPCError({ code: 'PRECONDITION_FAILED', message: 'Tokens are not available' });
|
|
68
|
+
}
|
|
69
|
+
return async (id) => ((await service.revoke(id)) ? null : 'not-active');
|
|
70
|
+
}
|
|
60
71
|
if (kind === 'share-link') {
|
|
61
72
|
const service = deps.shareTokens;
|
|
62
73
|
if (service === null) {
|
|
@@ -84,6 +95,40 @@ function revokerFor(kind, caller, deps) {
|
|
|
84
95
|
return (await internal.sessions.revoke(id, 'revoked-by-admin')) ? null : 'not-active';
|
|
85
96
|
};
|
|
86
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Revoke one credential BY ITS ID, whatever it is (D892). Every store is asked
|
|
100
|
+
* in turn; ids are random UUIDs per store, so at most one knows it. The
|
|
101
|
+
* caller's own credential is never revoked here.
|
|
102
|
+
*/
|
|
103
|
+
function revokerById(caller, deps) {
|
|
104
|
+
const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
|
|
105
|
+
return async (id) => {
|
|
106
|
+
if (id === caller.sessionId || id === caller.credentialId)
|
|
107
|
+
return 'current-session';
|
|
108
|
+
const tokens = deps.credentialTokens ?? null;
|
|
109
|
+
if (tokens !== null && (await tokens.get(id)) !== null) {
|
|
110
|
+
return (await tokens.revoke(id)) ? null : 'not-active';
|
|
111
|
+
}
|
|
112
|
+
if (internal !== null) {
|
|
113
|
+
if (internal.sessions.check(id).state === 'active') {
|
|
114
|
+
return (await internal.sessions.revoke(id, 'revoked-by-admin')) ? null : 'not-active';
|
|
115
|
+
}
|
|
116
|
+
if (await internal.adminTokens.revoke(id))
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (deps.shareTokens !== null &&
|
|
120
|
+
(await deps.shareTokens.revoke({ id, callerUserId: caller.id, callerIsAdmin: true }))) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (internal !== null) {
|
|
124
|
+
if (await internal.scopedTokens.revoke(id))
|
|
125
|
+
return null;
|
|
126
|
+
if (await internal.oauthSessions.revoke(id))
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return 'not-active';
|
|
130
|
+
};
|
|
131
|
+
}
|
|
87
132
|
function auditIds(ids) {
|
|
88
133
|
const joined = ids.join(',');
|
|
89
134
|
return joined.length <= AUDIT_IDS_MAX_CHARS ? joined : `${joined.slice(0, AUDIT_IDS_MAX_CHARS)}…`;
|
|
@@ -102,7 +147,9 @@ function createRevokeCredentialsProcedures(deps) {
|
|
|
102
147
|
message: `Confirmed ${input.confirmCount} but ${input.ids.length} were listed — nothing was revoked`,
|
|
103
148
|
});
|
|
104
149
|
}
|
|
105
|
-
const revokeOne =
|
|
150
|
+
const revokeOne = input.kind === undefined
|
|
151
|
+
? revokerById(ctx.user, deps)
|
|
152
|
+
: revokerFor(input.kind, ctx.user, deps);
|
|
106
153
|
const revoked = [];
|
|
107
154
|
const skipped = [];
|
|
108
155
|
// Sequential on purpose: the stores are single-writer, and a partial
|
|
@@ -122,7 +169,7 @@ function createRevokeCredentialsProcedures(deps) {
|
|
|
122
169
|
userId: ctx.user.id,
|
|
123
170
|
...(ip !== null ? { ip } : {}),
|
|
124
171
|
detail: {
|
|
125
|
-
kind: input.kind,
|
|
172
|
+
kind: input.kind ?? 'by-id',
|
|
126
173
|
requested: input.ids.length,
|
|
127
174
|
revoked: revoked.length,
|
|
128
175
|
skipped: skipped.length,
|
|
@@ -51,7 +51,7 @@ function requireSessions(registry) {
|
|
|
51
51
|
}
|
|
52
52
|
/** Real user sessions only — a scoped or share token has no login session to manage. */
|
|
53
53
|
function assertLoginSession(user) {
|
|
54
|
-
if (user.
|
|
54
|
+
if (user.userDelegated !== true) {
|
|
55
55
|
throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'auth.session-required', undefined, 'Requires a signed-in user session');
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TokenSummarySchema = exports.TokenFormatSchema = exports.MintTokenInputSchema = exports.TokenExpiresInSchema = exports.TOKEN_MAX_LIFETIME_SEC = void 0;
|
|
4
|
+
exports.summarizeToken = summarizeToken;
|
|
5
|
+
exports.requireCredentialTokens = requireCredentialTokens;
|
|
6
|
+
exports.mintFor = mintFor;
|
|
7
|
+
exports.createTokenProcedures = createTokenProcedures;
|
|
8
|
+
exports.daysToSeconds = daysToSeconds;
|
|
9
|
+
const types_1 = require("@camstack/types");
|
|
10
|
+
const server_1 = require("@trpc/server");
|
|
11
|
+
const zod_1 = require("zod");
|
|
12
|
+
const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
|
|
13
|
+
const scope_escalation_js_1 = require("../../auth/scope-escalation.js");
|
|
14
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
15
|
+
const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
|
|
16
|
+
const DAY_MS = 86_400_000;
|
|
17
|
+
/** Every token's longest lifetime — the admin token's bound, now everyone's (D816). */
|
|
18
|
+
exports.TOKEN_MAX_LIFETIME_SEC = 90 * 86_400;
|
|
19
|
+
const TOKEN_MIN_LIFETIME_SEC = 60;
|
|
20
|
+
const MAX_SCOPES = 32;
|
|
21
|
+
exports.TokenExpiresInSchema = zod_1.z.union([
|
|
22
|
+
zod_1.z.number().int().min(TOKEN_MIN_LIFETIME_SEC).max(exports.TOKEN_MAX_LIFETIME_SEC),
|
|
23
|
+
zod_1.z.literal('never'),
|
|
24
|
+
]);
|
|
25
|
+
exports.MintTokenInputSchema = zod_1.z.object({
|
|
26
|
+
scopes: zod_1.z.array(types_1.CredentialScopeSchema).min(1).max(MAX_SCOPES),
|
|
27
|
+
/** Seconds until expiry, or `'never'` (a share-view token only). */
|
|
28
|
+
expiresIn: exports.TokenExpiresInSchema,
|
|
29
|
+
label: zod_1.z.string().trim().min(1).max(80),
|
|
30
|
+
/** Required when `scopes` holds `'*'`. */
|
|
31
|
+
reauthToken: auth_reauth_procedures_js_1.ReauthTokenInput.optional(),
|
|
32
|
+
});
|
|
33
|
+
/** Where a listed token comes from: the unified format, or a legacy table still honoured. */
|
|
34
|
+
exports.TokenFormatSchema = zod_1.z.enum(['token', 'csa', 'cst', 'csv']);
|
|
35
|
+
exports.TokenSummarySchema = zod_1.z.object({
|
|
36
|
+
id: zod_1.z.string(),
|
|
37
|
+
subjectId: zod_1.z.string(),
|
|
38
|
+
label: zod_1.z.string(),
|
|
39
|
+
scopes: zod_1.z.array(types_1.CredentialScopeSchema),
|
|
40
|
+
createdAt: zod_1.z.number(),
|
|
41
|
+
expiresAt: zod_1.z.number().nullable(),
|
|
42
|
+
lastUsedAt: zod_1.z.number().nullable(),
|
|
43
|
+
revokedAt: zod_1.z.number().nullable(),
|
|
44
|
+
/** A pre-D892 token: valid until it expires, never minted again. */
|
|
45
|
+
legacy: zod_1.z.boolean(),
|
|
46
|
+
format: exports.TokenFormatSchema,
|
|
47
|
+
});
|
|
48
|
+
function summarizeToken(record) {
|
|
49
|
+
return {
|
|
50
|
+
id: record.id,
|
|
51
|
+
subjectId: record.subjectId,
|
|
52
|
+
label: record.label,
|
|
53
|
+
scopes: [...record.scopes],
|
|
54
|
+
createdAt: record.createdAt,
|
|
55
|
+
expiresAt: record.expiresAt,
|
|
56
|
+
lastUsedAt: record.lastUsedAt,
|
|
57
|
+
revokedAt: record.revokedAt,
|
|
58
|
+
legacy: false,
|
|
59
|
+
format: 'token',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function requireCredentialTokens(service) {
|
|
63
|
+
if (service === null) {
|
|
64
|
+
throw new server_1.TRPCError({ code: 'PRECONDITION_FAILED', message: 'Tokens are not available' });
|
|
65
|
+
}
|
|
66
|
+
return service;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The one mint, shared by `auth.mintToken` and every wrapper that kept its
|
|
70
|
+
* route (`createAdminToken`, `createShareToken`). Refuses; never trims.
|
|
71
|
+
*/
|
|
72
|
+
async function mintFor(deps, ctx, request) {
|
|
73
|
+
const user = ctx.user;
|
|
74
|
+
const provenanceOk = request.allowAccountLink === true ? user.userDelegated === true : user.interactive === true;
|
|
75
|
+
if (!provenanceOk) {
|
|
76
|
+
throw new server_1.TRPCError({
|
|
77
|
+
code: 'FORBIDDEN',
|
|
78
|
+
message: 'A token can only be minted from a signed-in session',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const share = (0, types_1.shareViewGrantOf)(request.scopes);
|
|
82
|
+
if (share !== null && request.scopes.length !== 1) {
|
|
83
|
+
throw new server_1.TRPCError({
|
|
84
|
+
code: 'BAD_REQUEST',
|
|
85
|
+
message: 'A share-view grant must be the token’s only scope',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (request.expiresIn === 'never' && share === null) {
|
|
89
|
+
throw new server_1.TRPCError({
|
|
90
|
+
code: 'BAD_REQUEST',
|
|
91
|
+
message: 'Only a share link may be minted without an expiry',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const verdict = (0, scope_escalation_js_1.scopesWithin)(request.scopes, user.scopes ?? [], ctx.deviceScopeLookup);
|
|
95
|
+
if (!verdict.ok) {
|
|
96
|
+
throw new server_1.TRPCError({
|
|
97
|
+
code: 'FORBIDDEN',
|
|
98
|
+
message: `A token cannot carry more than you hold: ${verdict.reason}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if ((0, types_1.scopesGrantAdmin)(request.scopes)) {
|
|
102
|
+
(0, auth_reauth_procedures_js_1.assertReauthenticated)(deps.auth, user, request.reauthToken);
|
|
103
|
+
}
|
|
104
|
+
const subjectId = request.subjectId ?? user.id;
|
|
105
|
+
if (subjectId !== user.id && !user.isAdmin) {
|
|
106
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Only an admin mints for another user' });
|
|
107
|
+
}
|
|
108
|
+
const service = requireCredentialTokens(deps.credentialTokens);
|
|
109
|
+
const minted = await service.mint({
|
|
110
|
+
subjectId,
|
|
111
|
+
createdBy: user.id,
|
|
112
|
+
label: request.label,
|
|
113
|
+
scopes: request.scopes,
|
|
114
|
+
expiresAt: request.expiresIn === 'never' ? null : Date.now() + request.expiresIn * 1000,
|
|
115
|
+
});
|
|
116
|
+
const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
|
|
117
|
+
if (internal !== null) {
|
|
118
|
+
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
119
|
+
await internal.audit
|
|
120
|
+
.record({
|
|
121
|
+
// An admin-capable token keeps the action the audit page already words.
|
|
122
|
+
action: (0, types_1.scopesGrantAdmin)(request.scopes) ? 'admin-token-created' : 'token-minted',
|
|
123
|
+
userId: user.id,
|
|
124
|
+
...(ip !== null ? { ip } : {}),
|
|
125
|
+
detail: {
|
|
126
|
+
tokenId: minted.record.id,
|
|
127
|
+
name: minted.record.label,
|
|
128
|
+
subjectId,
|
|
129
|
+
scopes: request.scopes.length,
|
|
130
|
+
...(minted.record.expiresAt !== null ? { expiresAt: minted.record.expiresAt } : {}),
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
.catch(() => { });
|
|
134
|
+
}
|
|
135
|
+
return minted;
|
|
136
|
+
}
|
|
137
|
+
async function legacyRows(deps, caller) {
|
|
138
|
+
const out = [];
|
|
139
|
+
const own = (subjectId) => caller.isAdmin || subjectId === caller.id;
|
|
140
|
+
const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
|
|
141
|
+
if (internal !== null && caller.isAdmin) {
|
|
142
|
+
for (const row of await internal.adminTokens.list()) {
|
|
143
|
+
out.push({
|
|
144
|
+
id: row.id,
|
|
145
|
+
subjectId: row.createdBy,
|
|
146
|
+
label: row.name,
|
|
147
|
+
scopes: ['*'],
|
|
148
|
+
createdAt: row.createdAt,
|
|
149
|
+
expiresAt: row.expiresAt,
|
|
150
|
+
lastUsedAt: row.lastUsedAt,
|
|
151
|
+
revokedAt: row.revokedAt,
|
|
152
|
+
legacy: true,
|
|
153
|
+
format: 'csa',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (deps.shareTokens !== null) {
|
|
158
|
+
const rows = caller.isAdmin
|
|
159
|
+
? await deps.shareTokens.listAll()
|
|
160
|
+
: await deps.shareTokens.listForUser(caller.id);
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
if (!own(row.userId))
|
|
163
|
+
continue;
|
|
164
|
+
out.push({
|
|
165
|
+
id: row.id,
|
|
166
|
+
subjectId: row.userId,
|
|
167
|
+
label: `share ${row.tokenPrefix}`,
|
|
168
|
+
scopes: [{ type: 'share-view', grant: row.scope }],
|
|
169
|
+
createdAt: row.createdAt,
|
|
170
|
+
expiresAt: row.expiresAt,
|
|
171
|
+
lastUsedAt: row.lastUsedAt ?? null,
|
|
172
|
+
revokedAt: null,
|
|
173
|
+
legacy: true,
|
|
174
|
+
format: 'csv',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function createTokenProcedures(deps) {
|
|
181
|
+
return {
|
|
182
|
+
/** Issue a token carrying `scopes` (see the module header). Shown once. */
|
|
183
|
+
mintToken: trpc_middleware_js_1.protectedProcedure
|
|
184
|
+
.input(exports.MintTokenInputSchema)
|
|
185
|
+
.output(zod_1.z.object({ token: zod_1.z.string(), record: exports.TokenSummarySchema }))
|
|
186
|
+
.mutation(async ({ ctx, input }) => {
|
|
187
|
+
const minted = await mintFor(deps, ctx, {
|
|
188
|
+
scopes: input.scopes,
|
|
189
|
+
expiresIn: input.expiresIn,
|
|
190
|
+
label: input.label,
|
|
191
|
+
...(input.reauthToken !== undefined ? { reauthToken: input.reauthToken } : {}),
|
|
192
|
+
});
|
|
193
|
+
return { token: minted.token, record: summarizeToken(minted.record) };
|
|
194
|
+
}),
|
|
195
|
+
/**
|
|
196
|
+
* The caller's tokens (an admin: everyone's) — unified and legacy alike,
|
|
197
|
+
* a legacy row flagged. No secret is ever listed. (The legacy `cst_`
|
|
198
|
+
* rows are listed by `userManagement.listScopedTokens`, as before.)
|
|
199
|
+
*/
|
|
200
|
+
listTokens: trpc_middleware_js_1.protectedProcedure
|
|
201
|
+
.input(zod_1.z.void())
|
|
202
|
+
.output(zod_1.z.array(exports.TokenSummarySchema))
|
|
203
|
+
.query(async ({ ctx }) => {
|
|
204
|
+
if (ctx.user.userDelegated !== true) {
|
|
205
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
|
|
206
|
+
}
|
|
207
|
+
const unified = deps.credentialTokens === null
|
|
208
|
+
? []
|
|
209
|
+
: await deps.credentialTokens.list(ctx.user.isAdmin ? undefined : ctx.user.id);
|
|
210
|
+
return [...unified.map(summarizeToken), ...(await legacyRows(deps, ctx.user))];
|
|
211
|
+
}),
|
|
212
|
+
/** Revoke one unified token: its owner or an admin. It stops working at once. */
|
|
213
|
+
revokeToken: trpc_middleware_js_1.protectedProcedure
|
|
214
|
+
.input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
|
|
215
|
+
.output(zod_1.z.object({ success: zod_1.z.boolean() }))
|
|
216
|
+
.mutation(async ({ ctx, input }) => {
|
|
217
|
+
if (ctx.user.userDelegated !== true) {
|
|
218
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'Requires a signed-in user session' });
|
|
219
|
+
}
|
|
220
|
+
const service = requireCredentialTokens(deps.credentialTokens);
|
|
221
|
+
const record = await service.get(input.id);
|
|
222
|
+
if (record === null)
|
|
223
|
+
return { success: false };
|
|
224
|
+
if (!ctx.user.isAdmin && record.subjectId !== ctx.user.id) {
|
|
225
|
+
throw new server_1.TRPCError({
|
|
226
|
+
code: 'FORBIDDEN',
|
|
227
|
+
message: 'Only the owner or an admin revokes a token',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const success = await service.revoke(input.id);
|
|
231
|
+
const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
|
|
232
|
+
if (success && internal !== null) {
|
|
233
|
+
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
234
|
+
await internal.audit
|
|
235
|
+
.record({
|
|
236
|
+
action: 'token-revoked',
|
|
237
|
+
userId: ctx.user.id,
|
|
238
|
+
...(ip !== null ? { ip } : {}),
|
|
239
|
+
detail: { tokenId: input.id },
|
|
240
|
+
})
|
|
241
|
+
.catch(() => { });
|
|
242
|
+
}
|
|
243
|
+
return { success };
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** Days → the `expiresIn` seconds the mint takes. */
|
|
248
|
+
function daysToSeconds(days) {
|
|
249
|
+
return (days * DAY_MS) / 1000;
|
|
250
|
+
}
|