Skip to content

Commit 44feca6

Browse files
committed
feat(sso): multi-identity-per-provider on /sso, gated by ALLOW_IDP_LINK_ANY_EMAIL
/sso now supports linking multiple identities of the same provider, gated by the existing ALLOW_IDP_LINK_ANY_EMAIL flag so the UI matches what the decision layer allows. - joinLinkedIdps gains perIdentity: dedupe by idpId:idpUserId (every identity shown, residue still collapsed) vs legacy idpId. - New linkableProviders helper: all active providers when multi-identity is on, else only not-yet-linked (legacy). - resolveSsoManagement reads env.ALLOW_IDP_LINK_ANY_EMAIL and threads it into both helpers; SsoManagementData.unlinked renamed to linkable; route renders each identity as its own unlinkable row. Flag off = byte-identical one-per-provider behavior. No decision-layer change: an already-mapped identity is still rejected (Shape-1 access-denied / ALREADY_EXISTS).
1 parent 04182c3 commit 44feca6

4 files changed

Lines changed: 95 additions & 17 deletions

File tree

app/resources/sso/sso-management.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// app/resources/sso/sso-management.ts
22
//
3-
// /sso loader business logic: list linked/unlinked IdPs for the session user.
3+
// /sso loader business logic: list linked IdPs + linkable providers for the session user.
44
// Extracted from sso.service.ts. Pure-internal decomposition — the
55
// `resolveSsoManagement` signature + `SsoManagementData`/`SsoManagementResult`
66
// shapes are unchanged and re-exported through the sso barrel.
@@ -30,7 +30,7 @@ export interface SsoManagementData {
3030
userId: string;
3131
loginName: string | null;
3232
linked: LinkedIdpView[];
33-
unlinked: IdProvider[];
33+
linkable: IdProvider[];
3434
allowUnlink: boolean;
3535
}
3636

@@ -42,13 +42,21 @@ export interface SsoManagementData {
4242
* link that errored mid-ceremony could leave two rows for one IdP). First occurrence wins.
4343
* Exported for unit testing the join + dedupe in isolation from the loader's I/O.
4444
*/
45-
export function joinLinkedIdps(links: IdpLink[], active: IdProvider[]): LinkedIdpView[] {
45+
export function joinLinkedIdps(
46+
links: IdpLink[],
47+
active: IdProvider[],
48+
perIdentity = false
49+
): LinkedIdpView[] {
4650
const byId = new Map<string, IdProvider>(active.map((p) => [p.id, p]));
4751
const seen = new Set<string>();
4852
const out: LinkedIdpView[] = [];
4953
for (const link of links) {
50-
if (seen.has(link.idpId)) continue; // dedupe by idpId — first occurrence wins
51-
seen.add(link.idpId);
54+
// perIdentity (multi-identity mode): dedupe on the full identity (idpId:idpUserId) so every
55+
// distinct identity gets a row while still collapsing partial-link residue (the SAME identity
56+
// duplicated mid-ceremony). Default (per-provider): dedupe on idpId — legacy one row per IdP.
57+
const key = perIdentity ? `${link.idpId}:${link.idpUserId}` : link.idpId;
58+
if (seen.has(key)) continue; // first occurrence wins
59+
seen.add(key);
5260
const provider = byId.get(link.idpId);
5361
out.push(
5462
provider
@@ -59,14 +67,31 @@ export function joinLinkedIdps(links: IdpLink[], active: IdProvider[]): LinkedId
5967
return out;
6068
}
6169

70+
/**
71+
* Providers offered in the "link an account" section. With multi-identity linking ON every active
72+
* provider is offered (you can always add another identity); with it OFF a provider is offered only
73+
* when no link for it exists yet (legacy one-per-provider).
74+
*/
75+
export function linkableProviders(
76+
active: IdProvider[],
77+
linked: LinkedIdpView[],
78+
allowMulti: boolean
79+
): IdProvider[] {
80+
if (allowMulti) return [...active];
81+
const linkedIds = new Set(linked.map((l) => l.idpId));
82+
return active.filter((p) => !linkedIds.has(p.id));
83+
}
84+
6285
export type SsoManagementResult =
6386
| { kind: 'redirect'; location: string }
6487
| { kind: 'data'; data: SsoManagementData; setCookie: string | null };
6588

6689
/**
6790
* /sso loader logic. Lists the active IdPs, resolves the ceremony session (guarding a
6891
* transient ProviderError into a service_unavailable redirect), and shapes the
69-
* linked/unlinked split. Returns a redirect to /login when there is no session user.
92+
* linked list + linkable providers (env.ALLOW_IDP_LINK_ANY_EMAIL gates multi-identity:
93+
* per-identity rows + all providers offered when on; legacy one-per-provider when off).
94+
* Returns a redirect to /login when there is no session user.
7095
*
7196
* `getCsrfToken` is injected so the route can wire the request-scoped CSRF token without
7297
* the service depending on the server CSRF module directly.
@@ -103,11 +128,10 @@ export async function resolveSsoManagement(
103128
return { kind: 'redirect', location: '/login' };
104129
}
105130

106-
// listIdpLinks now returns IdpLink[] — no cast needed.
131+
const allowMulti = env.ALLOW_IDP_LINK_ANY_EMAIL;
107132
const links = await provider.listIdpLinks(userId);
108-
// Join links ↔ active IdPs by idpId to attach {name,type,logoUrl} and dedupe.
109-
const linked = joinLinkedIdps(links, active);
110-
const linkedIds = new Set(linked.map((l) => l.idpId));
133+
// Join links ↔ active IdPs; per-identity rows when multi-identity linking is on.
134+
const linked = joinLinkedIdps(links, active, allowMulti);
111135

112136
return {
113137
kind: 'data',
@@ -116,7 +140,8 @@ export async function resolveSsoManagement(
116140
userId,
117141
loginName: session?.user?.loginName ?? null,
118142
linked,
119-
unlinked: active.filter((p) => !linkedIds.has(p.id)),
143+
// Multi on → offer every provider (add another); off → only providers with no link yet.
144+
linkable: linkableProviders(active, linked, allowMulti),
120145
allowUnlink: env.ALLOW_IDP_UNLINK,
121146
},
122147
setCookie: csrf.setCookie,

app/routes/sso/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export async function action({ request }: ActionFunctionArgs, deps: SsoActionDep
6666
// ---------------------------------------------------------------------------
6767

6868
export default function SsoPage() {
69-
const { csrfToken, loginName, linked, unlinked, allowUnlink } = useLoaderData<typeof loader>();
69+
const { csrfToken, loginName, linked, linkable, allowUnlink } = useLoaderData<typeof loader>();
7070

7171
return (
7272
<AuthCard
@@ -136,16 +136,16 @@ export default function SsoPage() {
136136

137137
{/* Divider between the connected list and the available-to-link list — only
138138
when both are present so a single-section page has no dangling rule. */}
139-
{linked.length > 0 && unlinked.length > 0 ? <Separator /> : null}
139+
{linked.length > 0 && linkable.length > 0 ? <Separator /> : null}
140140

141141
{/* Unlinked / available IdPs */}
142-
{unlinked.length > 0 ? (
142+
{linkable.length > 0 ? (
143143
<section className="flex flex-col gap-3">
144144
<h2 className="text-foreground text-sm font-medium">
145145
<Trans>Available accounts to link</Trans>
146146
</h2>
147147
<ul className="flex flex-col gap-2">
148-
{unlinked.map((idp: IdProvider) => (
148+
{linkable.map((idp: IdProvider) => (
149149
<li key={idp.id}>
150150
{/* RRForm: auto-adds ?index → posts to the sso index action. */}
151151
<RRForm method="post">

cypress/component/resources/sso/sso-management.cy.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// joinLinkedIdps is the pure link↔active-IdP join + dedupe — imported from the module (not the
55
// heavy barrel) so the browser bundle stays light; runs browser-side with Chai.
66
import type { IdpLink, IdProvider } from '@/modules/auth/types';
7-
import { joinLinkedIdps } from '@/resources/sso/sso-management';
7+
import { joinLinkedIdps, linkableProviders } from '@/resources/sso/sso-management';
88

99
const GOOGLE: IdProvider = {
1010
id: 'idp-google',
@@ -72,3 +72,56 @@ describe('joinLinkedIdps — 755-M6 join + dedupe', () => {
7272
expect(joinLinkedIdps([], [GOOGLE, GITHUB])).to.deep.equal([]);
7373
});
7474
});
75+
76+
describe('joinLinkedIdps — per-identity dedupe (multi-identity mode)', () => {
77+
it('keeps two identities of the SAME provider as separate rows when perIdentity=true', () => {
78+
const a = linkOf('idp-github', 'gh-a', 'a-handle');
79+
const b = linkOf('idp-github', 'gh-c', 'c-handle');
80+
const out = joinLinkedIdps([a, b], [GITHUB], true);
81+
expect(out).to.have.length(2);
82+
expect(out.map((v) => v.idpUserId)).to.deep.equal(['gh-a', 'gh-c']);
83+
expect(out.every((v) => v.name === 'GitHub')).to.equal(true);
84+
});
85+
86+
it('still collapses exact-duplicate identity rows (partial-link residue) when perIdentity=true', () => {
87+
const dup = linkOf('idp-github', 'gh-a', 'a-handle');
88+
const out = joinLinkedIdps([dup, { ...dup }], [GITHUB], true);
89+
expect(out).to.have.length(1);
90+
expect(out[0].idpUserId).to.equal('gh-a');
91+
});
92+
93+
it('per-provider dedupe (perIdentity=false / default) is unchanged — same idpId collapses', () => {
94+
const out = joinLinkedIdps(
95+
[linkOf('idp-github', 'gh-a'), linkOf('idp-github', 'gh-c')],
96+
[GITHUB],
97+
false
98+
);
99+
expect(out).to.have.length(1);
100+
expect(out[0].idpUserId).to.equal('gh-a');
101+
});
102+
});
103+
104+
describe('linkableProviders — env-gated link options', () => {
105+
it('returns ALL active providers when allowMulti=true (always offer another)', () => {
106+
const linked = joinLinkedIdps([linkOf('idp-github', 'gh-a')], [GITHUB], true);
107+
const out = linkableProviders([GOOGLE, GITHUB], linked, true);
108+
expect(out.map((p) => p.id)).to.deep.equal(['idp-google', 'idp-github']);
109+
});
110+
111+
it('filters out already-linked providers when allowMulti=false (legacy one-per-provider)', () => {
112+
const linked = joinLinkedIdps([linkOf('idp-github', 'gh-a')], [GITHUB], false);
113+
const out = linkableProviders([GOOGLE, GITHUB], linked, false);
114+
expect(out.map((p) => p.id)).to.deep.equal(['idp-google']);
115+
});
116+
117+
it('returns all active providers when nothing is linked (either mode)', () => {
118+
expect(linkableProviders([GOOGLE, GITHUB], [], true).map((p) => p.id)).to.deep.equal([
119+
'idp-google',
120+
'idp-github',
121+
]);
122+
expect(linkableProviders([GOOGLE, GITHUB], [], false).map((p) => p.id)).to.deep.equal([
123+
'idp-google',
124+
'idp-github',
125+
]);
126+
});
127+
});

cypress/component/routes/sso/sso-render.cy.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('SsoIndex — AuthFormFields csrf adoption', () => {
8383
userId: 'u1',
8484
loginName: 'you@acme.test',
8585
linked: [{ idpId: 'g', idpUserId: 'gx', idpUserName: 'Google You' }],
86-
unlinked: [{ id: 'gh', name: 'GitHub' }],
86+
linkable: [{ id: 'gh', name: 'GitHub' }],
8787
allowUnlink: true,
8888
};
8989

0 commit comments

Comments
 (0)