Skip to content

Commit c26d57e

Browse files
authored
Merge pull request #9 from fells-code/admin-bootstrap
Admin bootstrap
2 parents e05971c + b5928d2 commit c26d57e

30 files changed

Lines changed: 1702 additions & 347 deletions

.env.example

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ NODE_ENV=development
55
VERSION=1.0.0
66
APP_NAME=Seamless Auth Example
77
APP_ID=local-dev
8-
APP_ORIGIN=http://localhost:5001
8+
APP_ORIGIN=http://localhost:3000
99
ISSUER=http://localhost:5312
1010

1111
# "web" for website to auth server, "server" for api server to auth server auth
@@ -40,4 +40,11 @@ JWKS_ACTIVE_KID=dev-main
4040

4141
# WEBAUTHN
4242
RPID=localhost
43-
ORIGINS=http://localhost:5001
43+
ORIGINS=http://localhost:5173,http://localhost:5174
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

src/app.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,15 @@ import getLogger from './utils/logger.js';
2121
const logger = getLogger('app');
2222
const app = express();
2323

24-
const isValidUrl = (str: string) => {
25-
try {
26-
if (str === '*') return true;
27-
new URL(str);
28-
return true;
29-
} catch {
30-
throw new Error('Invalid host provied.');
31-
}
32-
};
33-
34-
const rawOrigin = process.env.APP_ORIGIN?.trim();
35-
const allowedOrigin = rawOrigin && isValidUrl(rawOrigin) ? rawOrigin : '';
24+
const rawOrigin = process.env.APP_ORIGINS!.split(',');
3625

3726
const corsOptions: CorsOptions = {
3827
origin: (origin, callback) => {
3928
if (!origin) {
4029
return callback(null, true);
4130
}
4231

43-
if (origin === allowedOrigin || origin === 'http://localhost:5174') {
32+
if (rawOrigin.includes(origin)) {
4433
return callback(null, true);
4534
}
4635

src/controllers/bootstrap.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
import getLogger from '../utils/logger.js';
16+
17+
const logger = getLogger('bootstrapAdminInvite');
18+
19+
function getBearerToken(req: Request): string | undefined {
20+
const auth = req.header('authorization');
21+
if (!auth) return undefined;
22+
23+
const [scheme, token] = auth.split(' ');
24+
if (scheme?.toLowerCase() !== 'bearer') return undefined;
25+
26+
return token;
27+
}
28+
29+
export async function createAdminBootstrapInviteHandler(req: Request, res: Response) {
30+
try {
31+
logger.info('Creating a bootstrap admin invitation');
32+
33+
const bearerToken = getBearerToken(req);
34+
35+
assertBootstrapSecret(bearerToken);
36+
await assertBootstrapAllowed();
37+
38+
const { email } = req.body;
39+
40+
const result = await createAdminBootstrapInvite({
41+
email,
42+
createdIp: req.ip ?? null,
43+
createdUserAgent: req.get('user-agent') ?? null,
44+
});
45+
46+
return res.status(201).json({
47+
success: true,
48+
data: {
49+
url: result.registrationUrl,
50+
expiresAt: result.expiresAt.toISOString(),
51+
token: result.token,
52+
},
53+
});
54+
} catch (error) {
55+
if (error instanceof BootstrapError) {
56+
return res.status(error.status).json({
57+
success: false,
58+
error: {
59+
code: error.code,
60+
message: error.message,
61+
},
62+
});
63+
}
64+
65+
return res.status(500).json({
66+
success: false,
67+
error: {
68+
code: 'BOOTSTRAP_INTERNAL_ERROR',
69+
message: 'An unexpected error occurred.',
70+
},
71+
});
72+
}
73+
}

src/controllers/magicLinks.ts

Lines changed: 38 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,47 @@ 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+
});
225+
226+
user.update({
227+
lastLogin: new Date(),
228+
});
249229

250-
return res.status(204).json({ message: 'Not verified.' });
230+
return;
231+
}
232+
return res.status(204).json({ message: 'Success' });
251233
}

0 commit comments

Comments
 (0)