pgkiln 0.62.0 → 0.64.0
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/SECURITY.md +4 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/db/migrations/132_app_user_mfa.sql +58 -0
- package/docs/apex-feature-parity.md +5 -2
- package/docs/guide/03-builder.md +46 -0
- package/docs/guide/08-security.md +48 -6
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -0
- package/docs/guide/18-cli.md +1 -0
- package/package.json +1 -1
- package/public/app.css +10 -0
- package/public/builder.css +4 -0
- package/src/account-mfa.ts +93 -0
- package/src/builder/advisor.ts +8 -0
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +56 -0
- package/src/builder/i18n/en.ts +56 -0
- package/src/builder/i18n/es.ts +56 -0
- package/src/builder/i18n/fr.ts +56 -0
- package/src/builder/i18n/nl.ts +56 -0
- package/src/builder/i18n/pt.ts +56 -0
- package/src/builder/ldap.ts +2 -0
- package/src/builder/login-page.ts +26 -2
- package/src/builder/routes.ts +137 -21
- package/src/builder/security.ts +3 -1
- package/src/builder/ui.ts +2 -1
- package/src/builder/users.ts +36 -3
- package/src/builder/workspaces.ts +37 -3
- package/src/cli/main.ts +19 -5
- package/src/devsso.ts +149 -0
- package/src/i18n/ar.ts +34 -0
- package/src/i18n/cs.ts +34 -0
- package/src/i18n/da.ts +34 -0
- package/src/i18n/de.ts +34 -0
- package/src/i18n/el.ts +34 -0
- package/src/i18n/es.ts +34 -0
- package/src/i18n/fi.ts +34 -0
- package/src/i18n/fr.ts +34 -0
- package/src/i18n/he.ts +34 -0
- package/src/i18n/it.ts +34 -0
- package/src/i18n/ja.ts +34 -0
- package/src/i18n/ko.ts +34 -0
- package/src/i18n/nb.ts +34 -0
- package/src/i18n/pl.ts +34 -0
- package/src/i18n/pt.ts +34 -0
- package/src/i18n/ru.ts +34 -0
- package/src/i18n/sv.ts +34 -0
- package/src/i18n/tr.ts +34 -0
- package/src/i18n/uk.ts +34 -0
- package/src/i18n/zh.ts +34 -0
- package/src/i18n.ts +68 -0
- package/src/metadata.ts +4 -1
- package/src/mfa.ts +76 -52
- package/src/remember.ts +1 -1
- package/src/runtime/account.ts +4 -1
- package/src/runtime/routes.ts +24 -9
- package/src/runtime/two-step.ts +395 -0
- package/src/saml.ts +13 -7
- package/src/sharedlogin.ts +1 -1
- package/src/sso.ts +37 -7
package/src/remember.ts
CHANGED
|
@@ -56,7 +56,7 @@ export async function useRemember(req: FastifyRequest, reply: FastifyReply, a: R
|
|
|
56
56
|
);
|
|
57
57
|
if (!row || !row.active) return clear(), null;
|
|
58
58
|
// an expired password needs the password form; access may have been withdrawn
|
|
59
|
-
if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
|
|
59
|
+
if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
|
|
60
60
|
const access = await ssoAccess(a.id, row.username, row.groups);
|
|
61
61
|
if (!access.allowed) return clear(), null;
|
|
62
62
|
return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, expiresAt: new Date(row.expires_at) };
|
package/src/runtime/account.ts
CHANGED
|
@@ -12,11 +12,13 @@ import { chrome, clientTexts } from './render.ts';
|
|
|
12
12
|
import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
|
|
13
13
|
import { loadApp } from '../metadata.ts';
|
|
14
14
|
import { pushSection } from './push.ts';
|
|
15
|
+
import { twoStepSection } from './two-step.ts';
|
|
15
16
|
import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
|
|
16
17
|
|
|
17
18
|
// "My account": details, own password, and preferences (light/dark and
|
|
18
19
|
// language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
|
|
19
|
-
// theme style per user); pgkiln provides the page itself.
|
|
20
|
+
// theme style per user); pgkiln provides the page itself. (sprint 130)
|
|
21
|
+
// Two-step sign-in: authenticator apps and recovery codes (two-step.ts).
|
|
20
22
|
|
|
21
23
|
/** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
|
|
22
24
|
async function saveStyle(appId: number, username: string, style: string) {
|
|
@@ -115,6 +117,7 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
|
|
|
115
117
|
<form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
|
|
116
118
|
</div></section>`
|
|
117
119
|
: ''}
|
|
120
|
+
${await twoStepSection(ctx)}
|
|
118
121
|
${await pushSection(ctx)}
|
|
119
122
|
</div>
|
|
120
123
|
</div>
|
package/src/runtime/routes.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { accountRoles, loadApp, loadPage, type App, type Page, type Region } fro
|
|
|
16
16
|
import { passwordDaysLeft, passwordProblem } from '../accounts.ts';
|
|
17
17
|
import { english, type Translate } from '../i18n.ts';
|
|
18
18
|
import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
|
|
19
|
-
import { enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type SsoResult } from '../sso.ts';
|
|
19
|
+
import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
|
|
20
20
|
import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
|
|
21
21
|
import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
|
|
22
22
|
import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
|
|
@@ -48,6 +48,8 @@ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translat
|
|
|
48
48
|
import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
|
|
49
49
|
import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
|
|
50
50
|
import { passwordResetRoutes } from './password-reset.ts';
|
|
51
|
+
import { passwordSignIn, twoStepRoutes } from './two-step.ts';
|
|
52
|
+
import { carriesSecondStep } from '../account-mfa.ts';
|
|
51
53
|
import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
|
|
52
54
|
import { availabilityRefusal } from './availability.ts';
|
|
53
55
|
import { feedbackRoutes } from './feedback.ts';
|
|
@@ -229,7 +231,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
229
231
|
// the session ended, but the browser was remembered: sign in again silently (a new token each time)
|
|
230
232
|
if (app.authentication === 'app_users' && !session.username) {
|
|
231
233
|
const remembered = await useRemember(req, reply, app);
|
|
232
|
-
|
|
234
|
+
// (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
|
|
235
|
+
if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
|
|
233
236
|
session = await signIn(req, reply, app, session, remembered.username, {
|
|
234
237
|
extraRoles: remembered.roles, groups: remembered.groups, method: remembered.method,
|
|
235
238
|
remember: true, rememberUntil: remembered.expiresAt, detail: 'remember me',
|
|
@@ -238,7 +241,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
238
241
|
// (067) another application of the session sharing group was signed in to: sign in here too
|
|
239
242
|
if (!session.username && sharingGroup(app)) {
|
|
240
243
|
const shared = await useShared(req, reply, app);
|
|
241
|
-
if (shared)
|
|
244
|
+
if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
|
|
242
245
|
session = await signIn(req, reply, app, session, shared.username, {
|
|
243
246
|
extraRoles: shared.roles, groups: shared.groups, method: shared.method, shared: shared.hash, detail: 'shared sign-in',
|
|
244
247
|
});
|
|
@@ -619,6 +622,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
619
622
|
fileRoutes(app, loadContext, txContext, forbidden);
|
|
620
623
|
// (087) "Forgot password?"
|
|
621
624
|
passwordResetRoutes(app);
|
|
625
|
+
twoStepRoutes(app);
|
|
622
626
|
// (079) the developer toolbar's session state viewer and actions
|
|
623
627
|
devbarRoutes(app, loadContext);
|
|
624
628
|
// (084) users' feedback
|
|
@@ -1272,7 +1276,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1272
1276
|
logActivity({ appId: a.id, username: account, event: 'login_failed', ip, detail: `ldap:${d.name}: no access` });
|
|
1273
1277
|
return fail(locale.t('login.no_access', { user: account, app: a.name }), 403);
|
|
1274
1278
|
}
|
|
1275
|
-
return
|
|
1279
|
+
return passwordSignIn(req, reply, a, session, account, {
|
|
1276
1280
|
next, remember, extraRoles: access.roles, groups: user.groups, method: `ldap:${d.name}`, detail: `ldap:${d.name}`,
|
|
1277
1281
|
});
|
|
1278
1282
|
}
|
|
@@ -1283,7 +1287,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1283
1287
|
logActivity({ appId: a.id, username: r.username, event: 'password_expired', ip });
|
|
1284
1288
|
return reply.type('text/html').send(expiredPage(a, locale, session, r.username, safeNext(a, next)));
|
|
1285
1289
|
}
|
|
1286
|
-
return
|
|
1290
|
+
return passwordSignIn(req, reply, a, session, r.username, { next, remember });
|
|
1287
1291
|
});
|
|
1288
1292
|
|
|
1289
1293
|
app.post('/a/:alias/password', async (req: Req, reply) => {
|
|
@@ -1309,7 +1313,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1309
1313
|
}
|
|
1310
1314
|
logActivity({ appId: a.id, username, event: 'password_changed', ip });
|
|
1311
1315
|
const canonical = (await runtime.one<{ username: string }>('select username from meta.account where lower(username) = lower($1)', [username]))!.username;
|
|
1312
|
-
return
|
|
1316
|
+
return passwordSignIn(req, reply, a, session, canonical, { next });
|
|
1313
1317
|
});
|
|
1314
1318
|
|
|
1315
1319
|
// ---------------------------------------------------------------- single sign-on (OpenID Connect)
|
|
@@ -1350,8 +1354,19 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1350
1354
|
});
|
|
1351
1355
|
|
|
1352
1356
|
/** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
|
|
1353
|
-
const finishSso = (req: FastifyRequest, reply: FastifyReply, p:
|
|
1354
|
-
|
|
1357
|
+
const finishSso = (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
|
|
1358
|
+
// (sprint 129) a sign-in to the builder
|
|
1359
|
+
if (result.appId === null) {
|
|
1360
|
+
const builder = builderSsoFinish();
|
|
1361
|
+
if (!builder) return simplePage(reply, 404, english('error.not_found'), english('login.method_unavailable'));
|
|
1362
|
+
return builder(req, reply, p, result, ip).catch((e) => {
|
|
1363
|
+
if (!(e instanceof SsoError)) throw e;
|
|
1364
|
+
logActivity({ username: result.link ?? result.username.slice(0, 100), event: 'login_failed', ip, detail: `builder sso:${p.name}: ${e.message}` });
|
|
1365
|
+
return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
|
|
1369
|
+
};
|
|
1355
1370
|
const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
|
|
1356
1371
|
const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
|
|
1357
1372
|
const loaded = alias ? await appWithLocale(req, alias) : undefined;
|
|
@@ -1363,7 +1378,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1363
1378
|
return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
|
|
1364
1379
|
}
|
|
1365
1380
|
const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
|
|
1366
|
-
return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, detail: `sso:${p.name}` });
|
|
1381
|
+
return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
|
|
1367
1382
|
};
|
|
1368
1383
|
|
|
1369
1384
|
// SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import {
|
|
3
|
+
accountCodesLeft, accountFactors, accountHasFactor, accountId, addAccountTotp, checkAccountCode, confirmAccountCodes, mfaOn, mfaRequired,
|
|
4
|
+
newAccountCodes, removeAccountFactor, secondStep, withSecondStep,
|
|
5
|
+
} from '../account-mfa.ts';
|
|
6
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
7
|
+
import { documentShell } from '../layout.ts';
|
|
8
|
+
import { accountRoles, type App } from '../metadata.ts';
|
|
9
|
+
import { matchTotp, newTotpSecret, otpauthUri, unbase32 } from '../mfa.ts';
|
|
10
|
+
import { qrSvg } from '../qrcode.ts';
|
|
11
|
+
import { secretKeyConfigured } from '../secrets.ts';
|
|
12
|
+
import { loginWindowMinutes } from '../security.ts';
|
|
13
|
+
import { clientIp, getSession, loginThrottled, logActivity, saveState, type Session } from '../session.ts';
|
|
14
|
+
import type { PageContext } from './context.ts';
|
|
15
|
+
import type { Locale } from './locale.ts';
|
|
16
|
+
import { pwaHead } from './pwa.ts';
|
|
17
|
+
import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, type Req } from './routes.ts';
|
|
18
|
+
|
|
19
|
+
// (sprint 130, SEC-3) The second step of signing in to an application, and "Two-step sign-in" on My account.
|
|
20
|
+
//
|
|
21
|
+
// A password or LDAP sign-in that the application asks a second step of (src/account-mfa.ts) doesn't get a session
|
|
22
|
+
// yet: what it would have become is kept in the signed-out session for five minutes (ten while adding an app), and
|
|
23
|
+
// /a/<alias>/login/2fa asks for a code from the authenticator app or a recovery code. When the application requires
|
|
24
|
+
// the step and the user has no app yet, the same page shows a QR code first, then the recovery codes once. Wrong
|
|
25
|
+
// codes count as failed sign-ins (the same lockout). The sign-in's method gets "+2fa", so a remembered or shared
|
|
26
|
+
// sign-in made from it may skip the step later.
|
|
27
|
+
|
|
28
|
+
const PENDING = '__MFA';
|
|
29
|
+
const STEP_SECONDS = 300;
|
|
30
|
+
const ENROL_SECONDS = 600;
|
|
31
|
+
|
|
32
|
+
interface Pending {
|
|
33
|
+
u: string;
|
|
34
|
+
next?: string;
|
|
35
|
+
remember?: boolean;
|
|
36
|
+
extraRoles?: string[];
|
|
37
|
+
groups?: string[];
|
|
38
|
+
method?: string;
|
|
39
|
+
detail?: string;
|
|
40
|
+
at: number;
|
|
41
|
+
enrol?: boolean;
|
|
42
|
+
/** while adding an app: the new secret, then (after its first code) the recovery codes to show once */
|
|
43
|
+
secret?: string;
|
|
44
|
+
codes?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pendingOf(s: Session): Pending | null {
|
|
48
|
+
try {
|
|
49
|
+
const p = JSON.parse(String(s.state[PENDING] ?? '')) as Pending;
|
|
50
|
+
const limit = (p?.enrol ? ENROL_SECONDS : STEP_SECONDS) * 1000;
|
|
51
|
+
return p && typeof p.u === 'string' && Date.now() - Number(p.at) < limit ? p : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PasswordSignIn {
|
|
58
|
+
next?: string;
|
|
59
|
+
remember?: boolean;
|
|
60
|
+
extraRoles?: string[];
|
|
61
|
+
groups?: string[];
|
|
62
|
+
method?: string;
|
|
63
|
+
detail?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A password or LDAP sign-in that succeeded: signed in at once, or on to the second step when the application asks
|
|
68
|
+
* for one of this user.
|
|
69
|
+
*/
|
|
70
|
+
export async function passwordSignIn(req: FastifyRequest, reply: FastifyReply, a: App, session: Session, username: string, opts: PasswordSignIn = {}) {
|
|
71
|
+
const roles = [...(await accountRoles(a.id, username)), ...(opts.extraRoles ?? []).map((r) => r.toLowerCase())];
|
|
72
|
+
const need = await secondStep(a, username, roles);
|
|
73
|
+
if (!need) return completeLogin(req, reply, a, session, username, opts);
|
|
74
|
+
const p: Pending = { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' };
|
|
75
|
+
session.state[PENDING] = JSON.stringify(p);
|
|
76
|
+
await saveState(session);
|
|
77
|
+
return reply.redirect(`/a/${a.alias}/login/2fa`, 303);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function card(a: App, locale: Locale, title: string, body: Raw) {
|
|
81
|
+
return documentShell(
|
|
82
|
+
`${title} · ${a.name}`,
|
|
83
|
+
html`<main class="login">
|
|
84
|
+
<div class="card login-card">
|
|
85
|
+
<h1>${title}</h1>
|
|
86
|
+
${body}
|
|
87
|
+
<a class="login-forgot" href="/a/${a.alias}/login">${locale.t('reset.back')}</a>
|
|
88
|
+
</div>
|
|
89
|
+
</main>`,
|
|
90
|
+
'login-body',
|
|
91
|
+
{},
|
|
92
|
+
pwaHead(a),
|
|
93
|
+
rootAttrs(locale),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const csrfField = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
|
|
98
|
+
const alert = (error?: string) => html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}`;
|
|
99
|
+
const grouped = (secret: string) => secret.replace(/(.{4})/g, '$1 ').trim();
|
|
100
|
+
|
|
101
|
+
function codesList(codes: string[]) {
|
|
102
|
+
return html`<ol class="mfa-codes">${codes.map((c) => html`<li><code>${c}</code></li>`)}</ol>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The second step's page: a code; or a new app (QR code, then its first code); or the recovery codes, once. */
|
|
106
|
+
function stepPage(a: App, locale: Locale, s: Session, p: Pending, error?: string) {
|
|
107
|
+
const t = locale.t;
|
|
108
|
+
const action = `/a/${a.alias}/login/2fa`;
|
|
109
|
+
if (p.codes)
|
|
110
|
+
return card(a, locale, t('mfa.codes_title'), html`<p>${t('mfa.codes_once')}</p>${codesList(p.codes)}
|
|
111
|
+
<form method="post" action="${action}/done" class="login-form">${csrfField(s)}
|
|
112
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
113
|
+
<button class="btn btn-hot">${t('mfa.continue')}</button></form>`);
|
|
114
|
+
if (p.enrol && p.secret)
|
|
115
|
+
return card(a, locale, t('mfa.enrol_title'), html`<p class="muted">${t('mfa.enrol_intro')}</p>${alert(error)}
|
|
116
|
+
<ol class="mfa-steps">
|
|
117
|
+
<li>${t('mfa.scan')}</li>
|
|
118
|
+
<li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(p.secret)}</code></li>
|
|
119
|
+
<li>${t('mfa.enter_code')}</li>
|
|
120
|
+
</ol>
|
|
121
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(p.secret, p.u, a.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
122
|
+
<form method="post" action="${action}" class="login-form">${csrfField(s)}
|
|
123
|
+
<div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7" autofocus></div>
|
|
124
|
+
<button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
|
|
125
|
+
return card(a, locale, t('mfa.step_title'), html`<p class="muted">${t('mfa.step_intro')}</p>${alert(error)}
|
|
126
|
+
<form method="post" action="${action}" class="login-form">${csrfField(s)}
|
|
127
|
+
<div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required autocomplete="one-time-code" maxlength="20" spellcheck="false" autocapitalize="off" autofocus></div>
|
|
128
|
+
<button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The signed-out session's pending sign-in, or a page that says it expired. */
|
|
132
|
+
async function start(req: Req, reply: FastifyReply) {
|
|
133
|
+
const loaded = await appWithLocale(req, req.params.alias);
|
|
134
|
+
if (!loaded) return null;
|
|
135
|
+
const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
|
|
136
|
+
const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
|
|
137
|
+
const p = session.username ? null : pendingOf(session);
|
|
138
|
+
return { a, locale, session, p };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const expired = async (reply: FastifyReply, a: App, locale: Locale, s: Session) => {
|
|
142
|
+
delete s.state[PENDING];
|
|
143
|
+
await saveState(s);
|
|
144
|
+
return reply.code(410).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('mfa.expired'))));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Sign in with the pending sign-in's options, now with "+2fa" in its method. */
|
|
148
|
+
async function finish(req: Req, reply: FastifyReply, a: App, s: Session, p: Pending, detail: string, next?: string, flash?: string) {
|
|
149
|
+
const n = await signIn(req, reply, a, s, p.u, {
|
|
150
|
+
extraRoles: p.extraRoles, groups: p.groups, remember: p.remember, method: withSecondStep(p.method),
|
|
151
|
+
detail: `${p.detail ?? 'password'} ${detail}`,
|
|
152
|
+
});
|
|
153
|
+
if (flash) {
|
|
154
|
+
n.state.__FLASH = flash;
|
|
155
|
+
await saveState(n);
|
|
156
|
+
}
|
|
157
|
+
return reply.redirect(next ?? safeNext(a, p.next), 303);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function twoStepRoutes(app: FastifyInstance) {
|
|
161
|
+
app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
162
|
+
const r = await start(req, reply);
|
|
163
|
+
if (!r) return reply.code(404).send('Not found');
|
|
164
|
+
const { a, locale, session: s, p } = r;
|
|
165
|
+
if (!p) return expired(reply, a, locale, s);
|
|
166
|
+
const id = await accountId(p.u);
|
|
167
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
168
|
+
if (p.enrol && !p.codes && !(await accountHasFactor(id))) {
|
|
169
|
+
// fail closed: without the instance key there is nowhere safe to keep the app's secret
|
|
170
|
+
if (!secretKeyConfigured()) return reply.code(503).type('text/html').send(card(a, locale, locale.t('mfa.enrol_title'), alert(locale.t('mfa.no_key'))));
|
|
171
|
+
if (!p.secret) {
|
|
172
|
+
p.secret = newTotpSecret();
|
|
173
|
+
s.state[PENDING] = JSON.stringify(p);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await saveState(s);
|
|
177
|
+
return reply.type('text/html').send(stepPage(a, locale, s, p));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
app.post('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
181
|
+
const r = await start(req, reply);
|
|
182
|
+
if (!r) return reply.code(404).send('Not found');
|
|
183
|
+
const { a, locale, session: s, p } = r;
|
|
184
|
+
const t = locale.t;
|
|
185
|
+
if (!p || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
186
|
+
const ip = clientIp(req);
|
|
187
|
+
const id = await accountId(p.u);
|
|
188
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
189
|
+
if (await loginThrottled(a.id, p.u, ip)) {
|
|
190
|
+
logActivity({ appId: a.id, username: p.u, event: 'login_locked', ip, detail: '2fa' });
|
|
191
|
+
delete s.state[PENDING];
|
|
192
|
+
await saveState(s);
|
|
193
|
+
return reply.code(429).type('text/html').send(card(a, locale, t('mfa.step_title'), alert(t('login.throttled', { minutes: loginWindowMinutes() }))));
|
|
194
|
+
}
|
|
195
|
+
const code = String(req.body?.code ?? '').slice(0, 40);
|
|
196
|
+
// adding the first app while signing in: its first code proves it works
|
|
197
|
+
if (p.enrol && p.secret && !p.codes) {
|
|
198
|
+
const step = matchTotp(unbase32(p.secret), code.replace(/\s/g, ''), null);
|
|
199
|
+
if (step === null) {
|
|
200
|
+
await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa (new app)' });
|
|
201
|
+
return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
|
|
202
|
+
}
|
|
203
|
+
await addAccountTotp(id, t('mfa.app_default'), p.secret, step);
|
|
204
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_enrolled', ip, detail: 'authenticator app (sign-in)' });
|
|
205
|
+
p.codes = await newAccountCodes(id);
|
|
206
|
+
delete p.secret;
|
|
207
|
+
s.state[PENDING] = JSON.stringify(p);
|
|
208
|
+
await saveState(s);
|
|
209
|
+
return reply.type('text/html').send(stepPage(a, locale, s, p));
|
|
210
|
+
}
|
|
211
|
+
const ok = await checkAccountCode(id, code);
|
|
212
|
+
if (!ok) {
|
|
213
|
+
await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa' });
|
|
214
|
+
return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
|
|
215
|
+
}
|
|
216
|
+
if (ok.kind === 'recovery') {
|
|
217
|
+
const left = await accountCodesLeft(id);
|
|
218
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
219
|
+
// to My account: one code fewer, maybe time for new ones
|
|
220
|
+
return finish(req, reply, a, s, p, '2fa (recovery code)', `/a/${a.alias}/account#two-step`, t('mfa.recovery_used', { n: left }));
|
|
221
|
+
}
|
|
222
|
+
return finish(req, reply, a, s, p, '2fa');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// the recovery codes were shown once after adding the first app while signing in
|
|
226
|
+
app.post('/a/:alias/login/2fa/done', async (req: Req, reply) => {
|
|
227
|
+
const r = await start(req, reply);
|
|
228
|
+
if (!r) return reply.code(404).send('Not found');
|
|
229
|
+
const { a, locale, session: s, p } = r;
|
|
230
|
+
if (!p?.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
231
|
+
if (req.body?.saved !== '1') return reply.code(422).type('text/html').send(stepPage(a, locale, s, p));
|
|
232
|
+
const id = await accountId(p.u);
|
|
233
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
234
|
+
await confirmAccountCodes(id);
|
|
235
|
+
return finish(req, reply, a, s, p, '2fa (new app)');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- My account → Two-step sign-in
|
|
239
|
+
const SECRET_MINUTES = 10;
|
|
240
|
+
const signedIn = async (req: Req, reply: FastifyReply) => {
|
|
241
|
+
const ctx = await loadContext(req, reply, { pageNo: 'home' });
|
|
242
|
+
if (!ctx) return null;
|
|
243
|
+
const id = await accountId(ctx.user);
|
|
244
|
+
if (req.body?.__csrf !== ctx.session.csrf_token || !mfaOn(ctx.app) || id === null) {
|
|
245
|
+
reply.redirect(`${ctx.base}/account`, 303);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return { ctx, id };
|
|
249
|
+
};
|
|
250
|
+
const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean) => {
|
|
251
|
+
if (flash) ctx.session.state[error ? '__FLASH_ERROR' : '__FLASH'] = flash;
|
|
252
|
+
await saveState(ctx.session);
|
|
253
|
+
return reply.redirect(`${ctx.base}/account#two-step`, 303);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
app.post('/a/:alias/account/2fa/start', async (req: Req, reply) => {
|
|
257
|
+
const r = await signedIn(req, reply);
|
|
258
|
+
if (!r) return;
|
|
259
|
+
if (secretKeyConfigured()) r.ctx.session.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
|
|
260
|
+
return done(reply, r.ctx);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
app.post('/a/:alias/account/2fa/cancel', async (req: Req, reply) => {
|
|
264
|
+
const r = await signedIn(req, reply);
|
|
265
|
+
if (!r) return;
|
|
266
|
+
delete r.ctx.session.state.__TOTP_NEW;
|
|
267
|
+
return done(reply, r.ctx);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
app.post('/a/:alias/account/2fa/confirm', async (req: Req, reply) => {
|
|
271
|
+
const r = await signedIn(req, reply);
|
|
272
|
+
if (!r) return;
|
|
273
|
+
const { ctx, id } = r;
|
|
274
|
+
const t = ctx.locale.t;
|
|
275
|
+
const pending = newSecret(ctx.session, SECRET_MINUTES);
|
|
276
|
+
if (!pending) {
|
|
277
|
+
delete ctx.session.state.__TOTP_NEW;
|
|
278
|
+
return done(reply, ctx, t('mfa.expired'), true);
|
|
279
|
+
}
|
|
280
|
+
const step = matchTotp(unbase32(pending), String(req.body?.code ?? '').replace(/\s/g, ''), null);
|
|
281
|
+
if (step === null) return done(reply, ctx, t('mfa.wrong'), true);
|
|
282
|
+
const first = !(await accountHasFactor(id));
|
|
283
|
+
await addAccountTotp(id, String(req.body?.name ?? '') || t('mfa.app_default'), pending, step);
|
|
284
|
+
delete ctx.session.state.__TOTP_NEW;
|
|
285
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_enrolled', ip: ctx.ip, detail: 'authenticator app' });
|
|
286
|
+
if (first || !(await accountCodesLeft(id))) ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(id));
|
|
287
|
+
return done(reply, ctx, t('mfa.added'));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
app.post('/a/:alias/account/2fa/codes', async (req: Req, reply) => {
|
|
291
|
+
const r = await signedIn(req, reply);
|
|
292
|
+
if (!r) return;
|
|
293
|
+
if (!(await accountHasFactor(r.id))) return done(reply, r.ctx);
|
|
294
|
+
r.ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(r.id));
|
|
295
|
+
await logActivity({ appId: r.ctx.app.id, username: r.ctx.user, event: '2fa_codes', ip: r.ctx.ip, detail: 'new recovery codes' });
|
|
296
|
+
return done(reply, r.ctx);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
app.post('/a/:alias/account/2fa/codes/confirm', async (req: Req, reply) => {
|
|
300
|
+
const r = await signedIn(req, reply);
|
|
301
|
+
if (!r) return;
|
|
302
|
+
if (req.body?.saved !== '1') return done(reply, r.ctx);
|
|
303
|
+
await confirmAccountCodes(r.id);
|
|
304
|
+
delete r.ctx.session.state.__CODES_NEW;
|
|
305
|
+
return done(reply, r.ctx);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// removing an app asks for a code (from any app, or a recovery code): a session left open is not enough
|
|
309
|
+
app.post('/a/:alias/account/2fa/:id/delete', async (req: Req, reply) => {
|
|
310
|
+
const r = await signedIn(req, reply);
|
|
311
|
+
if (!r) return;
|
|
312
|
+
const { ctx, id } = r;
|
|
313
|
+
const t = ctx.locale.t;
|
|
314
|
+
if (await loginThrottled(ctx.app.id, ctx.user, ctx.ip)) {
|
|
315
|
+
logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_locked', ip: ctx.ip, detail: '2fa remove' });
|
|
316
|
+
return done(reply, ctx, t('login.throttled', { minutes: loginWindowMinutes() }), true);
|
|
317
|
+
}
|
|
318
|
+
const factors = await accountFactors(id);
|
|
319
|
+
const factorId = Number((req.params as { id?: string }).id);
|
|
320
|
+
if (!factors.some((f) => f.id === factorId)) return done(reply, ctx);
|
|
321
|
+
if (factors.length === 1 && mfaRequired(ctx.app, ctx.roles)) return done(reply, ctx, t('mfa.keep_one'), true);
|
|
322
|
+
if (!(await checkAccountCode(id, String(req.body?.code ?? '').slice(0, 40)))) {
|
|
323
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_failed', ip: ctx.ip, detail: '2fa remove' });
|
|
324
|
+
return done(reply, ctx, t('mfa.wrong'), true);
|
|
325
|
+
}
|
|
326
|
+
await removeAccountFactor(id, factorId);
|
|
327
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_removed', ip: ctx.ip, detail: 'authenticator app' });
|
|
328
|
+
return done(reply, ctx, t('mfa.removed'));
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The app secret being added on My account (kept in the session for some minutes), or null. */
|
|
333
|
+
function newSecret(s: Session, minutes: number): string | null {
|
|
334
|
+
try {
|
|
335
|
+
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
336
|
+
return p && typeof p.secret === 'string' && Date.now() - Number(p.at) < minutes * 60_000 ? p.secret : null;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** My account → Two-step sign-in (when the application has a second step). */
|
|
343
|
+
export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
|
|
344
|
+
if (!mfaOn(ctx.app)) return '';
|
|
345
|
+
const id = await accountId(ctx.user);
|
|
346
|
+
if (id === null) return '';
|
|
347
|
+
const t = ctx.locale.t;
|
|
348
|
+
const [factors, left] = await Promise.all([accountFactors(id), accountCodesLeft(id)]);
|
|
349
|
+
const required = mfaRequired(ctx.app, ctx.roles);
|
|
350
|
+
const pending = newSecret(ctx.session, 10);
|
|
351
|
+
let shown: string[] | null = null;
|
|
352
|
+
try {
|
|
353
|
+
const c = JSON.parse(String(ctx.session.state.__CODES_NEW ?? ''));
|
|
354
|
+
shown = Array.isArray(c) ? c : null;
|
|
355
|
+
} catch {
|
|
356
|
+
shown = null;
|
|
357
|
+
}
|
|
358
|
+
const error = typeof ctx.session.state.__FLASH_ERROR === 'string' ? ctx.session.state.__FLASH_ERROR : '';
|
|
359
|
+
delete ctx.session.state.__FLASH_ERROR;
|
|
360
|
+
const base = `${ctx.base}/account/2fa`;
|
|
361
|
+
const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
|
|
362
|
+
const when = (v: string | null) => (v ? String(v).slice(0, 10) : '');
|
|
363
|
+
return html`<section class="region region-standard col-6" id="two-step"><header class="region-header"><h2>${t('mfa.step_title')}</h2></header><div class="region-body">
|
|
364
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
365
|
+
<p class="muted u-mt0">${factors.length ? t('mfa.on') : required ? t('mfa.required_you') : t('mfa.off')}</p>
|
|
366
|
+
${shown
|
|
367
|
+
? html`<h3>${t('mfa.codes_title')}</h3><p>${t('mfa.codes_once')}</p>${codesList(shown)}
|
|
368
|
+
<form method="post" action="${base}/codes/confirm">${csrf}
|
|
369
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
370
|
+
<div class="buttons"><button class="btn btn-hot">${t('mfa.continue')}</button></div></form>`
|
|
371
|
+
: ''}
|
|
372
|
+
${factors.length
|
|
373
|
+
? html`<ul class="mfa-factors">${factors.map((f) => html`<li><strong>${f.name}</strong> <span class="muted">${when(f.created_at)}</span>
|
|
374
|
+
<details><summary class="link-button">${t('mfa.remove')}</summary>
|
|
375
|
+
<form method="post" action="${base}/${f.id}/delete">${csrf}
|
|
376
|
+
<div class="field"><label class="label" for="rm_${f.id}">${t('mfa.remove_code')}</label><input id="rm_${f.id}" name="code" required autocomplete="one-time-code" maxlength="20"></div>
|
|
377
|
+
<div class="buttons"><button class="btn btn-danger">${t('mfa.remove')}</button></div></form></details></li>`)}</ul>
|
|
378
|
+
<p>${t('mfa.codes_left', { n: left })}</p>
|
|
379
|
+
<form method="post" action="${base}/codes">${csrf}<button class="btn">${t('mfa.codes_new')}</button></form>`
|
|
380
|
+
: ''}
|
|
381
|
+
${pending
|
|
382
|
+
? html`<h3>${t('mfa.add_app')}</h3>
|
|
383
|
+
<ol class="mfa-steps"><li>${t('mfa.scan')}</li><li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(pending)}</code></li><li>${t('mfa.enter_code')}</li></ol>
|
|
384
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(pending, ctx.user, ctx.app.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
385
|
+
<form method="post" action="${base}/confirm">${csrf}
|
|
386
|
+
<div class="field"><label class="label" for="mfa_name">${t('mfa.app_name')}</label><input id="mfa_name" name="name" value="${t('mfa.app_default')}" required maxlength="60"></div>
|
|
387
|
+
<div class="field"><label class="label" for="mfa_code">${t('mfa.code')}</label><input id="mfa_code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7"></div>
|
|
388
|
+
<div class="buttons"><button class="btn btn-hot">${t('mfa.confirm_app')}</button></div></form>
|
|
389
|
+
<form method="post" action="${base}/cancel">${csrf}<button class="link-button">${t('mfa.cancel')}</button></form>`
|
|
390
|
+
: secretKeyConfigured()
|
|
391
|
+
? html`<form method="post" action="${base}/start">${csrf}<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}">${t('mfa.add_app')}</button></div></form>`
|
|
392
|
+
: html`<p class="alert alert-info" role="status">${t('mfa.no_key')}</p>`}
|
|
393
|
+
</div></section>`;
|
|
394
|
+
}
|
|
395
|
+
|
package/src/saml.ts
CHANGED
|
@@ -76,7 +76,7 @@ function client(p: Provider, cache: CacheProvider) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
|
|
79
|
-
export async function startSamlSignIn(p: Provider, appId: number, next: string | undefined) {
|
|
79
|
+
export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
|
|
80
80
|
const state = b64url(randomBytes(32));
|
|
81
81
|
const browserKey = b64url(randomBytes(32));
|
|
82
82
|
await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
@@ -86,9 +86,9 @@ export async function startSamlSignIn(p: Provider, appId: number, next: string |
|
|
|
86
86
|
if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
|
|
87
87
|
// code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
|
|
88
88
|
await owner.query(
|
|
89
|
-
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
|
|
90
|
-
values ($1, $2, $3, '', $4, $5, $6)`,
|
|
91
|
-
[state, appId, p.id, requestId, sha256(browserKey), next ?? null],
|
|
89
|
+
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
|
|
90
|
+
values ($1, $2, $3, '', $4, $5, $6, $7)`,
|
|
91
|
+
[state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
|
|
92
92
|
);
|
|
93
93
|
return { url, browserKey };
|
|
94
94
|
}
|
|
@@ -99,7 +99,7 @@ const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (
|
|
|
99
99
|
export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
|
|
100
100
|
const pending = await owner.one(
|
|
101
101
|
`delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
|
|
102
|
-
returning app_id, nonce, browser_hash, next`,
|
|
102
|
+
returning app_id, nonce, browser_hash, next, link_username`,
|
|
103
103
|
[body.RelayState ?? '', p.id, PENDING_MINUTES],
|
|
104
104
|
);
|
|
105
105
|
if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
|
|
@@ -117,8 +117,14 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
|
|
|
117
117
|
if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
|
|
118
118
|
const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
|
|
119
119
|
if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
|
|
120
|
-
const
|
|
121
|
-
|
|
120
|
+
const name = values(profile.displayName ?? profile.cn)[0];
|
|
121
|
+
const email = values(profile.email ?? profile.mail)[0];
|
|
122
|
+
const groups = values(profile[p.groups_claim]);
|
|
123
|
+
if (pending.app_id === null)
|
|
124
|
+
// the builder: devsso.ts finds the developer (sprint 129)
|
|
125
|
+
return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
|
|
126
|
+
const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
|
|
127
|
+
return { appId: pending.app_id, next: pending.next, username: account, groups };
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
|
package/src/sharedlogin.ts
CHANGED
|
@@ -73,7 +73,7 @@ export async function useShared(req: FastifyRequest, reply: FastifyReply, a: Sha
|
|
|
73
73
|
await owner.query('delete from meta.shared_login where token_hash = $1', [hash]);
|
|
74
74
|
return clear(), null;
|
|
75
75
|
}
|
|
76
|
-
if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return null;
|
|
76
|
+
if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return null;
|
|
77
77
|
const access = await ssoAccess(a.id, row.username, row.groups);
|
|
78
78
|
if (!access.allowed) return null; // no access to this app: its own sign-in page (the shared sign-in stays for the others)
|
|
79
79
|
return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, hash };
|