pgkiln 0.62.0 → 0.63.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 +2 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/docs/apex-feature-parity.md +1 -1
- package/docs/guide/03-builder.md +46 -0
- package/docs/guide/08-security.md +2 -1
- package/docs/guide/12-development.md +3 -0
- package/docs/guide/18-cli.md +1 -0
- package/package.json +1 -1
- package/public/builder.css +4 -0
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +50 -0
- package/src/builder/i18n/en.ts +50 -0
- package/src/builder/i18n/es.ts +50 -0
- package/src/builder/i18n/fr.ts +50 -0
- package/src/builder/i18n/nl.ts +50 -0
- package/src/builder/i18n/pt.ts +50 -0
- package/src/builder/ldap.ts +2 -0
- 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 +2 -0
- package/src/builder/workspaces.ts +37 -3
- package/src/cli/main.ts +19 -5
- package/src/devsso.ts +149 -0
- package/src/runtime/routes.ts +14 -3
- package/src/saml.ts +13 -7
- package/src/sso.ts +37 -7
package/src/devsso.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { owner } from './db.ts';
|
|
3
|
+
import { SsoError } from './sso.ts';
|
|
4
|
+
|
|
5
|
+
// (sprint 129, SEC-1) Developers sign in to the builder through an identity
|
|
6
|
+
// provider (OpenID Connect, SAML) or an LDAP directory that has "builder"
|
|
7
|
+
// turned on. The rules, as for application accounts (sso.ts, ldap.ts):
|
|
8
|
+
// * a developer is found by the provider's stable subject
|
|
9
|
+
// (meta.developer_identity), never by a name the provider sends alone;
|
|
10
|
+
// * an existing developer with the same username is linked on the first
|
|
11
|
+
// sign-in only when the source allows it (builder_link_existing), with a
|
|
12
|
+
// verified e-mail address when that is the username, and never a
|
|
13
|
+
// break-glass developer; otherwise the developer links the identity
|
|
14
|
+
// themselves on the Security page, signed in;
|
|
15
|
+
// * a new developer is created when the source allows it
|
|
16
|
+
// (builder_auto_create), for members of builder_group when one is set;
|
|
17
|
+
// * builder_admin_group, when set, decides on every sign-in who is an
|
|
18
|
+
// instance administrator (break-glass developers and the last
|
|
19
|
+
// administrator are never demoted).
|
|
20
|
+
// A workspace can require these sign-ins of its developers (sso_only);
|
|
21
|
+
// break-glass developers may still use the pgkiln password.
|
|
22
|
+
|
|
23
|
+
export interface BuilderSource {
|
|
24
|
+
kind: 'provider' | 'directory';
|
|
25
|
+
id: number;
|
|
26
|
+
name: string;
|
|
27
|
+
builder_auto_create: boolean;
|
|
28
|
+
builder_link_existing: boolean;
|
|
29
|
+
builder_group: string | null;
|
|
30
|
+
builder_admin_group: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BuilderIdentity {
|
|
34
|
+
subject: string;
|
|
35
|
+
username: string;
|
|
36
|
+
name?: string | null;
|
|
37
|
+
email?: string | null;
|
|
38
|
+
/** false: the username is an unverified e-mail address and links nothing */
|
|
39
|
+
mayLink: boolean;
|
|
40
|
+
groups: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const column = (k: BuilderSource['kind']) => (k === 'provider' ? 'provider_id' : 'directory_id');
|
|
44
|
+
const validEmail = (v: unknown): v is string => typeof v === 'string' && /^[^@\s<>"(),;:\\]+@[^@\s<>"(),;:\\]+\.[^@\s<>"(),;:\\]+$/.test(v) && v.length <= 254;
|
|
45
|
+
|
|
46
|
+
/** The providers and directories developers can sign in with. */
|
|
47
|
+
export async function builderSources() {
|
|
48
|
+
const providers = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.auth_provider where enabled and builder order by display_name')).rows;
|
|
49
|
+
const directories = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.ldap_directory where enabled and builder order by display_name')).rows;
|
|
50
|
+
return { providers, directories };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function builderProvider(name: string) {
|
|
54
|
+
return owner.one('select * from meta.auth_provider where enabled and builder and name = $1', [name]);
|
|
55
|
+
}
|
|
56
|
+
export async function builderDirectory(name: string) {
|
|
57
|
+
return owner.one('select * from meta.ldap_directory where enabled and builder and name = $1', [name]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The developer for an identity (see the rules above); throws SsoError when there is none. */
|
|
61
|
+
export async function resolveDeveloper(src: BuilderSource, id: BuilderIdentity): Promise<string> {
|
|
62
|
+
const col = column(src.kind);
|
|
63
|
+
return owner.tx(async (c) => {
|
|
64
|
+
let username: string | undefined = (await c.query(`select username from meta.developer_identity where ${col} = $1 and subject = $2`, [src.id, id.subject])).rows[0]?.username;
|
|
65
|
+
if (!username) {
|
|
66
|
+
const existing = (
|
|
67
|
+
await c.query(
|
|
68
|
+
`select d.username, d.break_glass, exists (select 1 from meta.developer_identity i where i.username = d.username and i.${col} = $2) as linked
|
|
69
|
+
from meta.developer d where lower(d.username) = lower($1)`,
|
|
70
|
+
[id.username, src.id],
|
|
71
|
+
)
|
|
72
|
+
).rows[0];
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (existing.linked) throw new SsoError('This developer is already linked to another identity here.');
|
|
75
|
+
if (!src.builder_link_existing || !id.mayLink || existing.break_glass)
|
|
76
|
+
throw new SsoError(`A developer "${existing.username}" exists but is not linked to this identity. Sign in with the password and link it on the Security page.`);
|
|
77
|
+
username = existing.username as string;
|
|
78
|
+
} else {
|
|
79
|
+
const member = !src.builder_group || id.groups.includes(src.builder_group) || (!!src.builder_admin_group && id.groups.includes(src.builder_admin_group));
|
|
80
|
+
if (!src.builder_auto_create || !member) throw new SsoError(`There is no developer "${id.username}". Ask an administrator for access.`);
|
|
81
|
+
// a password nobody knows: the developer signs in through the provider (an administrator can set one)
|
|
82
|
+
username = (
|
|
83
|
+
await c.query(
|
|
84
|
+
`insert into meta.developer (username, password_hash, is_admin, email) values ($1, crypt($2, gen_salt('bf', 10)), false, $3) returning username`,
|
|
85
|
+
[id.username, randomBytes(32).toString('base64url'), validEmail(id.email) ? id.email : null],
|
|
86
|
+
)
|
|
87
|
+
).rows[0].username;
|
|
88
|
+
}
|
|
89
|
+
await c.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, id.subject, username]);
|
|
90
|
+
}
|
|
91
|
+
if (src.builder_admin_group) {
|
|
92
|
+
const admin = id.groups.includes(src.builder_admin_group);
|
|
93
|
+
// never the last administrator either: the instance would have none
|
|
94
|
+
await c.query(
|
|
95
|
+
`update meta.developer set is_admin = $2 where username = $1 and is_admin <> $2
|
|
96
|
+
and ($2 or (not break_glass and (select count(*) from meta.developer where is_admin) > 1))`,
|
|
97
|
+
[username, admin],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return username!;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Link an identity to a signed-in developer (Security page). */
|
|
105
|
+
export async function linkDeveloper(src: Pick<BuilderSource, 'kind' | 'id'>, subject: string, username: string) {
|
|
106
|
+
const col = column(src.kind);
|
|
107
|
+
const other = await owner.one(`select username from meta.developer_identity where ${col} = $1 and (subject = $2 or username = $3)`, [src.id, subject, username]);
|
|
108
|
+
if (other) throw new SsoError(other.username === username ? 'You already linked an identity here; unlink it first.' : 'This identity is linked to another developer.');
|
|
109
|
+
await owner.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, subject, username]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A developer's linked identities (Security page, provider pages). */
|
|
113
|
+
export async function identitiesOf(username: string) {
|
|
114
|
+
return (
|
|
115
|
+
await owner.query<{ id: number; source: string; display_name: string; kind: 'provider' | 'directory'; linked_at: string }>(
|
|
116
|
+
`select i.id, coalesce(p.name, d.name) as source, coalesce(p.display_name, d.display_name) as display_name,
|
|
117
|
+
case when i.provider_id is null then 'directory' else 'provider' end as kind, i.linked_at
|
|
118
|
+
from meta.developer_identity i
|
|
119
|
+
left join meta.auth_provider p on p.id = i.provider_id
|
|
120
|
+
left join meta.ldap_directory d on d.id = i.directory_id
|
|
121
|
+
where i.username = $1 order by 3`,
|
|
122
|
+
[username],
|
|
123
|
+
)
|
|
124
|
+
).rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Must the developer sign in through an identity provider? A member of a workspace that requires it,
|
|
129
|
+
* unless break-glass.
|
|
130
|
+
*/
|
|
131
|
+
export async function ssoRequired(username: string) {
|
|
132
|
+
const r = await owner.one(
|
|
133
|
+
`select not d.break_glass and exists (select 1 from meta.workspace_member m join meta.workspace w on w.id = m.workspace_id
|
|
134
|
+
where m.username = d.username and w.sso_only) as required
|
|
135
|
+
from meta.developer d where d.username = $1`,
|
|
136
|
+
[username],
|
|
137
|
+
);
|
|
138
|
+
return !!r?.required;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Instance administrators who may still sign in with the password. */
|
|
142
|
+
export async function breakGlassAdmins() {
|
|
143
|
+
return (await owner.query<{ username: string }>('select username from meta.developer where is_admin and break_glass order by username')).rows.map((r) => r.username);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `pgkiln admin builder-sign-in password`: every workspace allows the password again. */
|
|
147
|
+
export async function clearSsoOnly() {
|
|
148
|
+
return (await owner.query('update meta.workspace set sso_only = false where sso_only')).rowCount ?? 0;
|
|
149
|
+
}
|
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';
|
|
@@ -1350,8 +1350,19 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1350
1350
|
});
|
|
1351
1351
|
|
|
1352
1352
|
/** 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
|
-
|
|
1353
|
+
const finishSso = (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
|
|
1354
|
+
// (sprint 129) a sign-in to the builder
|
|
1355
|
+
if (result.appId === null) {
|
|
1356
|
+
const builder = builderSsoFinish();
|
|
1357
|
+
if (!builder) return simplePage(reply, 404, english('error.not_found'), english('login.method_unavailable'));
|
|
1358
|
+
return builder(req, reply, p, result, ip).catch((e) => {
|
|
1359
|
+
if (!(e instanceof SsoError)) throw e;
|
|
1360
|
+
logActivity({ username: result.link ?? result.username.slice(0, 100), event: 'login_failed', ip, detail: `builder sso:${p.name}: ${e.message}` });
|
|
1361
|
+
return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
|
|
1365
|
+
};
|
|
1355
1366
|
const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
|
|
1356
1367
|
const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
|
|
1357
1368
|
const loaded = alias ? await appWithLocale(req, alias) : undefined;
|
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/sso.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface Provider {
|
|
|
34
34
|
protocol: 'oidc' | 'saml';
|
|
35
35
|
idp_sso_url: string | null;
|
|
36
36
|
idp_cert: string | null;
|
|
37
|
+
/** (131) developers sign in to the builder with this provider too (devsso.ts) */
|
|
38
|
+
builder: boolean;
|
|
39
|
+
builder_auto_create: boolean;
|
|
40
|
+
builder_link_existing: boolean;
|
|
41
|
+
builder_group: string | null;
|
|
42
|
+
builder_admin_group: string | null;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
interface Discovery {
|
|
@@ -86,8 +92,11 @@ function jwks(doc: Discovery) {
|
|
|
86
92
|
return set;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Start: returns the provider URL to redirect to, and the browser-binding cookie value.
|
|
97
|
+
* appId null: a sign-in to the builder; link: the signed-in developer linking this identity.
|
|
98
|
+
*/
|
|
99
|
+
export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
|
|
91
100
|
const doc = await discover(p.issuer);
|
|
92
101
|
const state = b64url(randomBytes(32));
|
|
93
102
|
const nonce = b64url(randomBytes(32));
|
|
@@ -95,9 +104,9 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
|
|
|
95
104
|
const browserKey = b64url(randomBytes(32));
|
|
96
105
|
await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
97
106
|
await owner.query(
|
|
98
|
-
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
|
|
99
|
-
values ($1, $2, $3, $4, $5, $6, $7)`,
|
|
100
|
-
[state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null],
|
|
107
|
+
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
|
|
108
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
109
|
+
[state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null],
|
|
101
110
|
);
|
|
102
111
|
const url = new URL(doc.authorization_endpoint);
|
|
103
112
|
url.search = new URLSearchParams({
|
|
@@ -113,16 +122,29 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
|
|
|
113
122
|
return { url: url.toString(), browserKey };
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
// (sprint 129) the builder finishes its own sign-ins (app_id null); the callback routes are the runtime's
|
|
126
|
+
type BuilderFinish = (req: any, reply: any, p: Provider, result: SsoResult, ip: string) => Promise<unknown>;
|
|
127
|
+
let builderFinish: BuilderFinish | null = null;
|
|
128
|
+
export const onBuilderSso = (f: BuilderFinish) => {
|
|
129
|
+
builderFinish = f;
|
|
130
|
+
};
|
|
131
|
+
export const builderSsoFinish = () => builderFinish;
|
|
132
|
+
|
|
116
133
|
/** Read a claim by name or dot path (e.g. realm_access.roles). */
|
|
117
134
|
function claim(payload: JWTPayload, path: string): unknown {
|
|
118
135
|
return path.split('.').reduce<any>((v, k) => (v && typeof v === 'object' ? v[k] : undefined), payload);
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
export interface SsoResult {
|
|
122
|
-
|
|
139
|
+
/** null: a sign-in to the builder (131); username is then the provider's, not resolved to an account */
|
|
140
|
+
appId: number | null;
|
|
123
141
|
next: string | null;
|
|
124
142
|
username: string;
|
|
125
143
|
groups: string[];
|
|
144
|
+
/** builder sign-ins: the identity, for devsso.ts */
|
|
145
|
+
identity?: { subject: string; name: string | null; email: string | null; mayLink: boolean };
|
|
146
|
+
/** builder: the signed-in developer who links this identity (Security page) */
|
|
147
|
+
link?: string | null;
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
/** Callback: validate state/browser, exchange the code, verify the ID token, resolve the account. */
|
|
@@ -132,7 +154,7 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
132
154
|
const code = params.get('code') ?? '';
|
|
133
155
|
const pending = await owner.one(
|
|
134
156
|
`delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
|
|
135
|
-
returning app_id, code_verifier, nonce, browser_hash, next`,
|
|
157
|
+
returning app_id, code_verifier, nonce, browser_hash, next, link_username`,
|
|
136
158
|
[state, p.id, PENDING_MINUTES],
|
|
137
159
|
);
|
|
138
160
|
if (!pending) throw new SsoError('This sign-in link has expired or was already used. Please try again.');
|
|
@@ -181,6 +203,14 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
181
203
|
|
|
182
204
|
// an unverified e-mail address never links an existing account
|
|
183
205
|
const verified = p.username_claim !== 'email' || payload.email_verified === true;
|
|
206
|
+
if (pending.app_id === null) {
|
|
207
|
+
// the builder: devsso.ts finds the developer (sprint 129)
|
|
208
|
+
const email = payload.email_verified === false ? null : typeof payload.email === 'string' ? payload.email : null;
|
|
209
|
+
return {
|
|
210
|
+
appId: null, next: pending.next, username, groups, link: pending.link_username,
|
|
211
|
+
identity: { subject: payload.sub, name: typeof payload.name === 'string' ? payload.name : null, email, mayLink: verified },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
184
214
|
const account = await resolveAccount(p, payload, username, { mayLink: verified });
|
|
185
215
|
return { appId: pending.app_id, next: pending.next, username: account, groups };
|
|
186
216
|
}
|