Skip to content

Commit 7faf67d

Browse files
committed
feat: add admin bootstrapping
1 parent e05971c commit 7faf67d

27 files changed

Lines changed: 1691 additions & 335 deletions

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,13 @@ JWKS_ACTIVE_KID=dev-main
4141
# WEBAUTHN
4242
RPID=localhost
4343
ORIGINS=http://localhost:5001
44+
45+
# ADMIN BOOTSTRAP
46+
# Enables bootstrap feature
47+
SEAMLESS_BOOTSTRAP_ENABLED=true
48+
49+
# Secret used to authorize bootstrap invite creation
50+
SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123
51+
52+
# How long the invite (and cookie) is valid
53+
SEAMLESS_BOOTSTRAP_TTL_MINUTES=15

src/controllers/bootstrap.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright © 2026 Fells Code, LLC
3+
* Licensed under the GNU Affero General Public License v3.0
4+
* See LICENSE file in the project root for full license information
5+
*/
6+
7+
import { Request, Response } from 'express';
8+
9+
import {
10+
assertBootstrapAllowed,
11+
assertBootstrapSecret,
12+
BootstrapError,
13+
createAdminBootstrapInvite,
14+
} from '../services/bootstrapService.js';
15+
16+
function getBearerToken(req: Request): string | undefined {
17+
const auth = req.header('authorization');
18+
if (!auth) return undefined;
19+
20+
const [scheme, token] = auth.split(' ');
21+
if (scheme?.toLowerCase() !== 'bearer') return undefined;
22+
23+
return token;
24+
}
25+
26+
export async function createAdminBootstrapInviteHandler(req: Request, res: Response) {
27+
try {
28+
const bearerToken = getBearerToken(req);
29+
30+
assertBootstrapSecret(bearerToken);
31+
await assertBootstrapAllowed();
32+
33+
const { email } = req.body;
34+
35+
const result = await createAdminBootstrapInvite({
36+
email,
37+
createdIp: req.ip ?? null,
38+
createdUserAgent: req.get('user-agent') ?? null,
39+
});
40+
41+
return res.status(201).json({
42+
success: true,
43+
data: {
44+
url: result.registrationUrl,
45+
expiresAt: result.expiresAt.toISOString(),
46+
token: result.token,
47+
},
48+
});
49+
} catch (error) {
50+
if (error instanceof BootstrapError) {
51+
return res.status(error.status).json({
52+
success: false,
53+
error: {
54+
code: error.code,
55+
message: error.message,
56+
},
57+
});
58+
}
59+
60+
return res.status(500).json({
61+
success: false,
62+
error: {
63+
code: 'BOOTSTRAP_INTERNAL_ERROR',
64+
message: 'An unexpected error occurred.',
65+
},
66+
});
67+
}
68+
}

src/controllers/magicLinks.ts

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,16 @@ import { Request, Response } from 'express';
99
import { Op } from 'sequelize';
1010

1111
import { getSystemConfig } from '../config/getSystemConfig.js';
12-
import { setAuthCookies } from '../lib/cookie.js';
13-
import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../lib/token.js';
1412
import { AuthEvent } from '../models/authEvents.js';
1513
import { MagicLinkToken } from '../models/magicLinks.js';
16-
import { Session } from '../models/sessions.js';
1714
import { User } from '../models/users.js';
1815
import { AuthEventService } from '../services/authEventService.js';
16+
import { maybePromoteBootstrapAdmin } from '../services/bootstrapPromotionService.js';
1917
import { sendMagicLinkEmail } from '../services/messagingService.js';
18+
import { issueSessionAndRespond } from '../services/sessionIssuance.js';
2019
import { AuthenticatedRequest } from '../types/types.js';
2120
import getLogger from '../utils/logger.js';
22-
import {
23-
computeSessionTimes,
24-
hashDeviceFingerprint,
25-
hashSha256,
26-
parseDurationToSeconds,
27-
} from '../utils/utils.js';
21+
import { hashDeviceFingerprint, hashSha256 } from '../utils/utils.js';
2822

2923
const logger = getLogger('magic-links');
3024

@@ -193,59 +187,43 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
193187
req,
194188
});
195189

196-
const refreshToken = generateRefreshToken();
197-
const refreshTokenHash = await hashRefreshToken(refreshToken);
198-
const { expiresAt, idleExpiresAt } = computeSessionTimes();
199-
200-
const session = await Session.create({
201-
userId: user.id,
202-
infraId: process.env.APP_ID!,
203-
mode: AUTH_MODE,
204-
refreshTokenHash,
205-
userAgent: req.get('user-agent'),
206-
ipAddress: req.ip,
207-
expiresAt,
208-
idleExpiresAt,
209-
lastUsedAt: undefined,
210-
});
211-
212-
const token = await signAccessToken(session.id, user.id, user.roles);
213-
214190
user.challenge = '';
215191
user.verified = true;
216192

217193
await user.save();
218194

219-
if (token && refreshToken) {
220-
await AuthEvent.create({
221-
user_id: user.id,
222-
type: 'registration_success',
223-
ip_address: req.ip,
224-
user_agent: req.headers['user-agent'],
225-
metadata: {},
226-
});
227-
228-
if (AUTH_MODE === 'web') {
229-
await setAuthCookies(res, { accessToken: token, refreshToken });
230-
res.status(200).json({ message: 'Success' });
231-
return;
232-
}
233-
234-
const { access_token_ttl, refresh_token_ttl } = await getSystemConfig();
235-
236-
return res.status(200).json({
237-
message: 'Success',
238-
token,
239-
refreshToken,
240-
sub: user.id,
241-
roles: user.roles,
195+
const bootstrapResult = await maybePromoteBootstrapAdmin({
196+
user,
197+
req,
198+
completionMethod: 'magic_link_fallback',
199+
});
200+
201+
if (bootstrapResult.promoted) {
202+
logger.info(`Bootstrap admin granted to ${user.email}`);
203+
}
204+
205+
await AuthEvent.create({
206+
user_id: user.id,
207+
type: 'registration_success',
208+
ip_address: req.ip,
209+
user_agent: req.headers['user-agent'],
210+
metadata: {},
211+
});
212+
213+
await issueSessionAndRespond({
214+
user: {
215+
id: user.id,
242216
email: user.email,
243217
phone: user.phone,
244-
ttl: parseDurationToSeconds(access_token_ttl || '15m'),
245-
refreshTtl: parseDurationToSeconds(refresh_token_ttl || '1h'),
246-
});
247-
}
248-
}
218+
roles: user.roles ?? [],
219+
},
220+
req,
221+
res,
222+
authMode: AUTH_MODE,
223+
clearBootstrap: true,
224+
});
249225

250-
return res.status(204).json({ message: 'Not verified.' });
226+
return res.json({ message: 'Success' });
227+
}
228+
return res.status(204).json({ message: 'Success' });
251229
}

0 commit comments

Comments
 (0)