History

Fill OAuth connection labels from account identity instead of the owner default

Rhys Sullivan committed 3 months ago

Commit e4be2f61 parent fc1e58910 files · +376 −52Browse files

Unified · Split

10 files
  1. e2e/scenarios/google-health-checks.test.ts +67 −5
  2. packages/core/api/src/handlers/oauth.ts +1 −0
  3. packages/core/api/src/oauth/api.ts +3 −0
  4. packages/core/sdk/src/executor.ts +12 −3
  5. packages/core/sdk/src/oauth-client.ts +8 −0
  6. packages/core/sdk/src/oauth-flow.test.ts +109 −0
  7. packages/core/sdk/src/oauth-service.ts +44 −3
  8. packages/react/src/components/add-account-modal.test.ts +42 −9
  9. packages/react/src/components/add-account-modal.tsx +86 −32
  10. packages/react/src/plugins/oauth-sign-in.tsx +4 −0
e2e/scenarios/google-health-checks.test.ts+67−5
@@ -116,6 +116,7 @@ const connectGoogleAccount = (input: { · 115 unmodified lines
116 readonly target: TargetShape;116 readonly target: TargetShape;
117 readonly integration: IntegrationSlug;117 readonly integration: IntegrationSlug;
118 readonly oauthClient: OAuthClientSlug;118 readonly oauthClient: OAuthClientSlug;
119 readonly newConnection?: boolean;
119}) =>120}) =>
120 Effect.gen(function* () {121 Effect.gen(function* () {
121 const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();122 const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
@@ -146,6 +147,25 @@ const connectGoogleAccount = (input: { · 24 unmodified lines
146 },147 },
147 });148 });
148 149
150 return yield* runGoogleOAuthFlow(input, redirectUri);
151 });
152
153/** Run one authorize+complete round through the already-registered app. Split
154 * from `connectGoogleAccount` so a scenario can connect a SECOND account
155 * through the same app (`newConnection: true`) without re-registering it. */
156const runGoogleOAuthFlow = (
157 input: {
158 readonly client: Client;
159 readonly target: TargetShape;
160 readonly integration: IntegrationSlug;
161 readonly oauthClient: OAuthClientSlug;
162 readonly newConnection?: boolean;
163 },
164 redirectUriOverride?: string,
165) =>
166 Effect.gen(function* () {
167 const redirectUri =
168 redirectUriOverride ?? new URL("/api/oauth/callback", input.target.baseUrl).toString();
149 const started = yield* input.client.oauth.start({169 const started = yield* input.client.oauth.start({
150 payload: {170 payload: {
151 client: input.oauthClient,171 client: input.oauthClient,
@@ -155,6 +175,7 @@ const connectGoogleAccount = (input: { · 3 unmodified lines
155 integration: input.integration,175 integration: input.integration,
156 template: GOOGLE_AUTH_TEMPLATE,176 template: GOOGLE_AUTH_TEMPLATE,
157 redirectUri,177 redirectUri,
178 ...(input.newConnection === true ? { newConnection: true } : {}),
158 },179 },
159 });180 });
160 expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");181 expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
@@ -167,6 +188,7 @@ const connectGoogleAccount = (input: { · 6 unmodified lines
167 expect(completed.integration, "OAuth completion creates the connection").toBe(188 expect(completed.integration, "OAuth completion creates the connection").toBe(
168 input.integration,189 input.integration,
169 );190 );
191 return completed;
170 });192 });
171 193
172scenario(194scenario(
@@ -334,13 +356,53 @@ scenario( · 161 unmodified lines
334 refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel,356 refreshed.find((connection) => connection.name === CONNECTION)?.identityLabel,
335 "Google Sheets still shows the OAuth grant identity after no-probe health",357 "Google Sheets still shows the OAuth grant identity after no-probe health",
336 ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);358 ).toBe(GOOGLE_EMULATOR_ACCOUNT_EMAIL);
359
360 // Reconnecting the SAME connection must not clobber a curated label
361 // with the grant identity.
362 yield* client.connections.update({
363 params: { owner: "org", integration: slug, name: CONNECTION },
364 payload: { identityLabel: "Finance account" },
365 });
366 yield* runGoogleOAuthFlow({ client, target, integration: slug, oauthClient });
367 const reconnected = yield* client.connections.list({
368 query: { owner: "org", integration: slug },
369 });
370 expect(
371 reconnected.find((connection) => connection.name === CONNECTION)?.identityLabel,
372 "reconnect keeps the curated label over the grant identity",
373 ).toBe("Finance account");
374
375 // A `newConnection` connect under a taken name mints a SECOND
376 // connection with a suffixed name instead of replacing the first.
377 const second = yield* runGoogleOAuthFlow({
378 client,
379 target,
380 integration: slug,
381 oauthClient,
382 newConnection: true,
383 });
384 expect(String(second.name), "second account mints a suffixed connection").toBe(
385 `${String(CONNECTION)}2`,
386 );
387 expect(second.identityLabel, "second account label comes from the grant identity").toBe(
388 GOOGLE_EMULATOR_ACCOUNT_EMAIL,
389 );
390 const both = yield* client.connections.list({
391 query: { owner: "org", integration: slug },
392 });
393 expect(
394 both.map((connection) => String(connection.name)).sort(),
395 "both accounts coexist",
396 ).toEqual([String(CONNECTION), `${String(CONNECTION)}2`]);
337 }),397 }),
338 Effect.gen(function* () {398 Effect.gen(function* () {
339 yield* client.connections399 for (const name of [CONNECTION, ConnectionName.make(`${String(CONNECTION)}2`)]) {
340 .remove({400 yield* client.connections
341 params: { owner: "org", integration: slug, name: CONNECTION },401 .remove({
342 })402 params: { owner: "org", integration: slug, name },
343 .pipe(Effect.ignore);403 })
404 .pipe(Effect.ignore);
405 }
344 yield* client.oauth406 yield* client.oauth
345 .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })407 .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
346 .pipe(Effect.ignore);408 .pipe(Effect.ignore);
packages/core/api/src/handlers/oauth.ts+1−0
@@ -161,6 +161,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 160 unmodified lines
161 integration: payload.integration,161 integration: payload.integration,
162 template: payload.template,162 template: payload.template,
163 identityLabel: payload.identityLabel,163 identityLabel: payload.identityLabel,
164 newConnection: payload.newConnection,
164 redirectUri: payload.redirectUri,165 redirectUri: payload.redirectUri,
165 });166 });
166 return startResultToResponse(result);167 return startResultToResponse(result);
packages/core/api/src/oauth/api.ts+3−0
@@ -160,6 +160,9 @@ const StartPayload = Schema.Struct({ · 159 unmodified lines
160 integration: IntegrationSlug,160 integration: IntegrationSlug,
161 template: AuthTemplateSlug,161 template: AuthTemplateSlug,
162 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),162 identityLabel: Schema.optional(Schema.NullOr(Schema.String)),
163 /** Mint a NEW connection: a taken `name` resolves to the next free suffixed
164 * name server-side instead of re-minting the existing row. */
165 newConnection: Schema.optional(Schema.Boolean),
163 redirectUri: Schema.optional(Schema.NullOr(Schema.String)),166 redirectUri: Schema.optional(Schema.NullOr(Schema.String)),
164});167});
165 168
packages/core/sdk/src/executor.ts+12−3
@@ -2489,14 +2489,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2488 unmodified lines
2489 integration: input.integration,2489 integration: input.integration,
2490 name,2490 name,
2491 };2491 };
2492 // Label precedence: an explicit (user-chosen) label always wins; a
2493 // derived label (OIDC claims) only FILLS an empty slot. Like
2494 // `description` below, a reconnect or token refresh must not erase a
2495 // label the user curated. Resolved once, used by every write below.
2496 let identityLabel: string | null = null;
2492 yield* transaction(2497 yield* transaction(
2493 Effect.gen(function* () {2498 Effect.gen(function* () {
2494 const existing = yield* findConnectionRow(ref);2499 const existing = yield* findConnectionRow(ref);
2500 const existingLabel = existing?.identity_label?.trim() ? existing.identity_label : null;
2501 identityLabel =
2502 input.identityLabel ?? existingLabel ?? input.derivedIdentityLabel ?? null;
2495 const set: Record<string, unknown> = {2503 const set: Record<string, unknown> = {
2496 template: String(input.template),2504 template: String(input.template),
2497 provider: input.provider,2505 provider: input.provider,
2498 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },2506 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2499 identity_label: input.identityLabel ?? null,2507 identity_label: identityLabel,
2500 oauth_client: String(input.oauthClient),2508 oauth_client: String(input.oauthClient),
2501 oauth_client_owner: input.oauthClientOwner,2509 oauth_client_owner: input.oauthClientOwner,
2502 refresh_item_id: input.refreshItemId,2510 refresh_item_id: input.refreshItemId,
@@ -2529,7 +2537,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
2529 template: String(input.template),2537 template: String(input.template),
2530 provider: input.provider,2538 provider: input.provider,
2531 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },2539 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2532 identity_label: input.identityLabel ?? null,2540 identity_label: identityLabel,
2533 // Curated description: never stamped by a mint — a reconnect2541 // Curated description: never stamped by a mint — a reconnect
2534 // or token refresh must not erase what the user wrote.2542 // or token refresh must not erase what the user wrote.
2535 description: null,2543 description: null,
@@ -2568,7 +2576,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 32 unmodified lines
2568 template: String(input.template),2576 template: String(input.template),
2569 provider: input.provider,2577 provider: input.provider,
2570 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },2578 item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
2571 identity_label: input.identityLabel ?? null,2579 identity_label: identityLabel,
2572 description: null,2580 description: null,
2573 oauth_client: String(input.oauthClient),2581 oauth_client: String(input.oauthClient),
2574 oauth_client_owner: input.oauthClientOwner,2582 oauth_client_owner: input.oauthClientOwner,
@@ -3848,6 +3856,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1273 unmodified lines
3848 ownedKeys: (owner: Owner) => ownedKeys(owner),3856 ownedKeys: (owner: Owner) => ownedKeys(owner),
3849 defaultWritableProvider,3857 defaultWritableProvider,
3850 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),3858 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),
3859 connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)),
3851 // One integration-row read + one projector run. Resolve the method this3860 // One integration-row read + one projector run. Resolve the method this
3852 // template selects exactly as the runtime's `selectAuthMethod` does —3861 // template selects exactly as the runtime's `selectAuthMethod` does —
3853 // exact slug match, else the sole declared method (single-method3862 // exact slug match, else the sole declared method (single-method
packages/core/sdk/src/oauth-client.ts+8−0
@@ -127,7 +127,15 @@ export interface OAuthStartInput { · 126 unmodified lines
127 readonly name: ConnectionName;127 readonly name: ConnectionName;
128 readonly integration: IntegrationSlug;128 readonly integration: IntegrationSlug;
129 readonly template: AuthTemplateSlug;129 readonly template: AuthTemplateSlug;
130 /** Display label for the minted connection. When omitted, the server derives
131 * one from the provider's OIDC account claims (the account email) when the
132 * grant carries them; a re-mint of an existing connection keeps its stored
133 * label. Send a value only for a label the user actually chose. */
130 readonly identityLabel?: string | null;134 readonly identityLabel?: string | null;
135 /** Mint a NEW connection: when `name` is already taken, the mint picks the
136 * next free suffixed name (`personalGmail2`) instead of re-minting the
137 * existing row. Omit for reconnect flows, which target the existing row. */
138 readonly newConnection?: boolean;
131 /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */139 /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */
132 readonly redirectUri?: string | null;140 readonly redirectUri?: string | null;
133}141}
packages/core/sdk/src/oauth-flow.test.ts+109−0
@@ -384,6 +384,115 @@ describe("oauth.start / oauth.complete", () => { · 383 unmodified lines
384 ),384 ),
385 );385 );
386 386
387 it.effect("newConnection resolves a taken name to the next free suffix", () =>
388 Effect.scoped(
389 Effect.gen(function* () {
390 const server = yield* serveOAuthTestServer({
391 scopes: ["openid", "email", "profile", "read"],
392 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
393 });
394 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
395 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
396
397 yield* executor.oauth.createClient({
398 owner: "org",
399 slug: CLIENT,
400 authorizationUrl: server.authorizationEndpoint,
401 tokenUrl: server.tokenEndpoint,
402 grant: "authorization_code",
403 clientId: "test-client",
404 clientSecret: "test-secret",
405 });
406
407 const runFlow = Effect.gen(function* () {
408 const started = yield* executor.oauth.start({
409 owner: "org",
410 client: CLIENT,
411 clientOwner: "org",
412 name: ConnectionName.make("main"),
413 integration: INTEG,
414 template: TEMPLATE,
415 newConnection: true,
416 });
417 expect(started.status).toBe("redirect");
418 if (started.status !== "redirect") return null;
419 const callback = yield* server.completeAuthorizationCodeFlow({
420 authorizationUrl: started.authorizationUrl,
421 });
422 return yield* executor.oauth.complete({
423 state: started.state,
424 code: callback.code,
425 });
426 });
427
428 const first = yield* runFlow;
429 const second = yield* runFlow;
430 expect(String(first?.name)).toBe("main");
431 expect(String(second?.name)).toBe("main2");
432
433 const connections = yield* executor.connections.list({ integration: INTEG });
434 expect(connections.map((connection) => String(connection.name)).sort()).toEqual([
435 "main",
436 "main2",
437 ]);
438 }),
439 ),
440 );
441
442 it.effect("preserves a curated label when reconnecting without an explicit label", () =>
443 Effect.scoped(
444 Effect.gen(function* () {
445 const server = yield* serveOAuthTestServer({
446 scopes: ["openid", "email", "profile", "read"],
447 idTokenClaims: { email: "alice@example.com", sub: "user-1" },
448 });
449 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
450 yield* executor.acme.seed(["openid", "email", "profile", "read"]);
451
452 yield* executor.oauth.createClient({
453 owner: "org",
454 slug: CLIENT,
455 authorizationUrl: server.authorizationEndpoint,
456 tokenUrl: server.tokenEndpoint,
457 grant: "authorization_code",
458 clientId: "test-client",
459 clientSecret: "test-secret",
460 });
461
462 const runFlow = Effect.gen(function* () {
463 const started = yield* executor.oauth.start({
464 owner: "org",
465 client: CLIENT,
466 clientOwner: "org",
467 name: ConnectionName.make("main"),
468 integration: INTEG,
469 template: TEMPLATE,
470 });
471 expect(started.status).toBe("redirect");
472 if (started.status !== "redirect") return null;
473 const callback = yield* server.completeAuthorizationCodeFlow({
474 authorizationUrl: started.authorizationUrl,
475 });
476 return yield* executor.oauth.complete({
477 state: started.state,
478 code: callback.code,
479 });
480 });
481
482 const first = yield* runFlow;
483 expect(first?.identityLabel).toBe("alice@example.com");
484
485 // The user renames the connection, then reconnects (no typed label).
486 yield* executor.connections.update(
487 { owner: "org", integration: INTEG, name: ConnectionName.make("main") },
488 { identityLabel: "Finance account" },
489 );
490 const second = yield* runFlow;
491 expect(second?.identityLabel).toBe("Finance account");
492 }),
493 ),
494 );
495
387 it.effect("does not overwrite connection identity from id_token claims on refresh", () =>496 it.effect("does not overwrite connection identity from id_token claims on refresh", () =>
388 Effect.scoped(497 Effect.scoped(
389 Effect.gen(function* () {498 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+44−3
@@ -83,6 +83,10 @@ export interface MintOAuthConnectionInput { · 82 unmodified lines
83 readonly integration: IntegrationSlug;83 readonly integration: IntegrationSlug;
84 readonly template: AuthTemplateSlug;84 readonly template: AuthTemplateSlug;
85 readonly identityLabel?: string | null;85 readonly identityLabel?: string | null;
86 /** Display label derived from the provider (OIDC id_token claims), as opposed
87 * to `identityLabel` which the user chose. Only fills an EMPTY label slot:
88 * a re-mint must never clobber a curated label with a derived one. */
89 readonly derivedIdentityLabel?: string | null;
86 /** Credential provider key + item id the access token is stored under. */90 /** Credential provider key + item id the access token is stored under. */
87 readonly provider: string;91 readonly provider: string;
88 readonly itemId: string;92 readonly itemId: string;
@@ -127,6 +131,14 @@ export interface OAuthServiceDeps { · 38 unmodified lines
127 readonly mintOAuthConnection: (131 readonly mintOAuthConnection: (
128 input: MintOAuthConnectionInput,132 input: MintOAuthConnectionInput,
129 ) => Effect.Effect<Connection, StorageFailure>;133 ) => Effect.Effect<Connection, StorageFailure>;
134 /** Whether a connection row exists under `(owner, integration, name)`: the
135 * raw row, not the policy-filtered list, so `start` can resolve a free
136 * name for `newConnection` flows against what is actually stored. */
137 readonly connectionNameTaken: (ref: {
138 readonly owner: Owner;
139 readonly integration: IntegrationSlug;
140 readonly name: ConnectionName;
141 }) => Effect.Effect<boolean, StorageFailure>;
130 /**142 /**
131 * Resolve the OAuth scope policy for a `(integration, template)`:143 * Resolve the OAuth scope policy for a `(integration, template)`:
132 * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template144 * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template
@@ -1060,6 +1072,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 927 unmodified lines
1060 });1072 });
1061 }1073 }
1062 1074
1075 // newConnection: resolve the requested name to a FREE one against the
1076 // stored rows (not a client-side, policy-filtered view), so a second
1077 // untyped connect mints `personalGmail2` instead of silently re-minting
1078 // the first account's row. Reconnects omit the flag and keep targeting
1079 // their existing row. Bounded: a pathological owner with 1000 same-named
1080 // connections fails loudly rather than scanning forever.
1081 let name = input.name;
1082 if (input.newConnection === true) {
1083 let suffix = 2;
1084 while (
1085 yield* deps.connectionNameTaken({
1086 owner: input.owner,
1087 integration: input.integration,
1088 name,
1089 })
1090 ) {
1091 if (suffix > 1000) {
1092 return yield* new OAuthStartError({
1093 message: `No free connection name derivable from ${input.name}.`,
1094 });
1095 }
1096 name = ConnectionName.make(`${String(input.name)}${suffix}`);
1097 suffix++;
1098 }
1099 }
1100
1063 // Declared scopes win (driven by the selected auth template). MCP-style1101 // Declared scopes win (driven by the selected auth template). MCP-style
1064 // integrations declare none and discover them from the client's protected1102 // integrations declare none and discover them from the client's protected
1065 // resource / authorization server metadata at connect.1103 // resource / authorization server metadata at connect.
@@ -1107,7 +1145,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 41 unmodified lines
1107 ),1145 ),
1108 );1146 );
1109 const connection = yield* mintFromToken(1147 const connection = yield* mintFromToken(
1110 input,1148 { ...input, name },
1111 client,1149 client,
1112 token,1150 token,
1113 requestedScopes,1151 requestedScopes,
@@ -1163,7 +1201,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 49 unmodified lines
1163 state: String(state),1201 state: String(state),
1164 client_slug: String(input.client),1202 client_slug: String(input.client),
1165 integration: String(input.integration),1203 integration: String(input.integration),
1166 name: String(input.name),1204 name: String(name),
1167 template: String(input.template),1205 template: String(input.template),
1168 redirect_url: flowRedirectUri,1206 redirect_url: flowRedirectUri,
1169 pkce_verifier: verifier,1207 pkce_verifier: verifier,
@@ -1308,7 +1346,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 138 unmodified lines
1308 name: session.name,1346 name: session.name,
1309 integration: session.integration,1347 integration: session.integration,
1310 template: session.template,1348 template: session.template,
1311 identityLabel: session.identityLabel ?? token.idTokenIdentityLabel ?? null,1349 identityLabel: session.identityLabel ?? null,
1312 },1350 },
1313 client,1351 client,
1314 token,1352 token,
@@ -1384,6 +1422,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 69 unmodified lines
1384 integration: target.integration,1422 integration: target.integration,
1385 template: target.template,1423 template: target.template,
1386 identityLabel: target.identityLabel ?? null,1424 identityLabel: target.identityLabel ?? null,
1425 // The OIDC account claims travel separately: they may only FILL an
1426 // empty label, never replace a user-curated one on reconnect.
1427 derivedIdentityLabel: token.idTokenIdentityLabel ?? null,
1387 provider: String(provider.key),1428 provider: String(provider.key),
1388 itemId,1429 itemId,
1389 oauthClient: OAuthClientSlug.make(client.slug),1430 oauthClient: OAuthClientSlug.make(client.slug),
packages/react/src/components/add-account-modal.test.ts+42−9
@@ -19,6 +19,8 @@ import { · 18 unmodified lines
19 oauthIdentityLabelFromHealth,19 oauthIdentityLabelFromHealth,
20 runCimdConnect,20 runCimdConnect,
21 runDcrConnect,21 runDcrConnect,
22 typedIdentityLabel,
23 uniqueConnectionName,
22} from "./add-account-modal";24} from "./add-account-modal";
23 25
24const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): AuthMethod => ({26const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): AuthMethod => ({
@@ -111,6 +113,32 @@ describe("connectionNameFrom", () => { · 86 unmodified lines
111 });113 });
112});114});
113 115
116describe("uniqueConnectionName", () => {
117 const base = connectionNameFrom("", "user", "Gmail", "org_123");
118
119 it("keeps the derived name when it is not taken", () => {
120 expect(String(uniqueConnectionName(base, new Set()))).toBe("personalGmail");
121 });
122
123 it("suffixes past taken names so a second untyped connect mints a NEW connection", () => {
124 expect(String(uniqueConnectionName(base, new Set(["personalGmail"])))).toBe("personalGmail2");
125 expect(String(uniqueConnectionName(base, new Set(["personalGmail", "personalGmail2"])))).toBe(
126 "personalGmail3",
127 );
128 });
129});
130
131describe("typedIdentityLabel", () => {
132 it("returns undefined for empty/whitespace labels so oauth.start omits the label", () => {
133 expect(typedIdentityLabel("")).toBeUndefined();
134 expect(typedIdentityLabel(" ")).toBeUndefined();
135 });
136
137 it("returns the trimmed label the user typed", () => {
138 expect(typedIdentityLabel(" Work Gmail ")).toBe("Work Gmail");
139 });
140});
141
114describe("oauthIdentityLabelFromHealth", () => {142describe("oauthIdentityLabelFromHealth", () => {
115 const healthyResult = {143 const healthyResult = {
116 status: "healthy" as const,144 status: "healthy" as const,
@@ -118,13 +146,12 @@ describe("oauthIdentityLabelFromHealth", () => { · 1 unmodified line
118 checkedAt: 1,146 checkedAt: 1,
119 };147 };
120 148
121 it("uses a healthy probed identity when the stored label is still the default", () => {149 it("fills an unset label (untyped connect with no OIDC claims)", () => {
122 expect(150 expect(
123 oauthIdentityLabelFromHealth({151 oauthIdentityLabelFromHealth({
124 result: healthyResult,152 result: healthyResult,
125 typedLabel: "",153 typedLabel: "",
126 storedIdentityLabel: "Personal Google",154 storedIdentityLabel: null,
127 defaultIdentityLabel: "Personal Google",
128 }),155 }),
129 ).toBe("user@example.com");156 ).toBe("user@example.com");
130 });157 });
@@ -135,7 +162,6 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines
135 result: healthyResult,162 result: healthyResult,
136 typedLabel: "My Google Account",163 typedLabel: "My Google Account",
137 storedIdentityLabel: "My Google Account",164 storedIdentityLabel: "My Google Account",
138 defaultIdentityLabel: "Personal Google",
139 }),165 }),
140 ).toBeNull();166 ).toBeNull();
141 });167 });
@@ -146,7 +172,16 @@ describe("oauthIdentityLabelFromHealth", () => { · 4 unmodified lines
146 result: healthyResult,172 result: healthyResult,
147 typedLabel: "",173 typedLabel: "",
148 storedIdentityLabel: "Finance Google",174 storedIdentityLabel: "Finance Google",
149 defaultIdentityLabel: "Personal Google",175 }),
176 ).toBeNull();
177 });
178
179 it("does not overwrite a label already filled from OIDC claims", () => {
180 expect(
181 oauthIdentityLabelFromHealth({
182 result: healthyResult,
183 typedLabel: "",
184 storedIdentityLabel: "alice@example.com",
150 }),185 }),
151 ).toBeNull();186 ).toBeNull();
152 });187 });
@@ -156,16 +191,14 @@ describe("oauthIdentityLabelFromHealth", () => { · 3 unmodified lines
156 oauthIdentityLabelFromHealth({191 oauthIdentityLabelFromHealth({
157 result: { status: "degraded", checkedAt: 1 },192 result: { status: "degraded", checkedAt: 1 },
158 typedLabel: "",193 typedLabel: "",
159 storedIdentityLabel: "Personal Google",194 storedIdentityLabel: null,
160 defaultIdentityLabel: "Personal Google",
161 }),195 }),
162 ).toBeNull();196 ).toBeNull();
163 expect(197 expect(
164 oauthIdentityLabelFromHealth({198 oauthIdentityLabelFromHealth({
165 result: { status: "healthy", checkedAt: 1 },199 result: { status: "healthy", checkedAt: 1 },
166 typedLabel: "",200 typedLabel: "",
167 storedIdentityLabel: "Personal Google",201 storedIdentityLabel: null,
168 defaultIdentityLabel: "Personal Google",
169 }),202 }),
170 ).toBeNull();203 ).toBeNull();
171 });204 });
packages/react/src/components/add-account-modal.tsx+86−32
@@ -573,18 +573,41 @@ export const connectionNameFrom = ( · 572 unmodified lines
573): ConnectionName =>573): ConnectionName =>
574 connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId));574 connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId));
575 575
576/** Uniquify a derived callable name against the owner's existing connections
577 * for the integration: a fresh OAuth connect mints a NEW connection, and two
578 * untyped connects both derive `personalGmail`; without a suffix the second
579 * silently replaces the first account's token and label. */
580export const uniqueConnectionName = (
581 base: ConnectionName,
582 takenNames: ReadonlySet<string>,
583): ConnectionName => {
584 if (!takenNames.has(String(base))) return base;
585 let suffix = 2;
586 while (takenNames.has(`${String(base)}${suffix}`)) suffix++;
587 return ConnectionName.make(`${String(base)}${suffix}`);
588};
589
590/** The `oauth.start` identity label: only a label the user actually typed.
591 * An untyped connect sends none, so the server may fill the label from the
592 * provider's OIDC claims (the account email) instead of a generic
593 * "Personal Gmail" that would also clobber a curated label on reconnect. */
594export const typedIdentityLabel = (label: string): string | undefined => {
595 const trimmed = label.trim();
596 return trimmed.length > 0 ? trimmed : undefined;
597};
598
576export const oauthIdentityLabelFromHealth = (input: {599export const oauthIdentityLabelFromHealth = (input: {
577 readonly result: HealthCheckResult;600 readonly result: HealthCheckResult;
578 readonly typedLabel: string;601 readonly typedLabel: string;
579 readonly storedIdentityLabel?: string | null;602 readonly storedIdentityLabel?: string | null;
580 readonly defaultIdentityLabel: string;
581}): string | null => {603}): string | null => {
582 const identity = input.result.identity?.trim();604 const identity = input.result.identity?.trim();
583 if (input.result.status !== "healthy" || !identity) return null;605 if (input.result.status !== "healthy" || !identity) return null;
584 if (input.typedLabel.trim().length > 0) return null;606 if (input.typedLabel.trim().length > 0) return null;
585 const defaultLabel = input.defaultIdentityLabel.trim();607 // Fill only an unset label: an untyped connect stores none unless the grant
586 const storedLabel = (input.storedIdentityLabel ?? defaultLabel).trim();608 // carried OIDC claims, and anything already stored (typed, curated, or
587 return storedLabel === defaultLabel ? identity : null;609 // claims-derived) wins over a probed identity.
610 return (input.storedIdentityLabel ?? "").trim().length === 0 ? identity : null;
588};611};
589 612
590// ---------------------------------------------------------------------------613// ---------------------------------------------------------------------------
@@ -1241,6 +1264,30 @@ function AddAccountModalView(props: AddAccountModalProps) { · 650 unmodified lines
1241 () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []),1264 () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []),
1242 [connectionsResult],1265 [connectionsResult],
1243 );1266 );
1267 // PREVIEW-ONLY uniquify against the loaded connections list: the callable
1268 // name shown before an OAuth connect anticipates the server's suffixing
1269 // (`personalGmail2`). The AUTHORITATIVE resolution happens in `oauth.start`
1270 // against stored rows (`newConnection: true`); this view may be stale or
1271 // policy-filtered and must never be the thing preventing an overwrite.
1272 const takenConnectionNames = useMemo<ReadonlyMap<Owner, ReadonlySet<string>>>(() => {
1273 const map = new Map<Owner, Set<string>>();
1274 if (!AsyncResult.isSuccess(connectionsResult)) return map;
1275 for (const connection of connectionsResult.value) {
1276 if (String(connection.integration) !== String(integration)) continue;
1277 const names = map.get(connection.owner) ?? new Set<string>();
1278 names.add(String(connection.name));
1279 map.set(connection.owner, names);
1280 }
1281 return map;
1282 }, [connectionsResult, integration]);
1283 const previewConnectionName = useCallback(
1284 (typed: string, connectionOwner: Owner): ConnectionName =>
1285 uniqueConnectionName(
1286 connectionNameFrom(typed, connectionOwner, integrationName, organizationId),
1287 takenConnectionNames.get(connectionOwner) ?? new Set<string>(),
1288 ),
1289 [takenConnectionNames, integrationName, organizationId],
1290 );
1244 1291
1245 const method = useMemo(1292 const method = useMemo(
1246 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],1293 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],
@@ -1523,7 +1570,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 276 unmodified lines
1523 const savedToOptions = isOAuth && !automaticOAuthActive ? oauthConnectionOptions : ownerOptions;1570 const savedToOptions = isOAuth && !automaticOAuthActive ? oauthConnectionOptions : ownerOptions;
1524 const savedToOwner = isOAuth && !automaticOAuthActive ? oauthConnectionOwner : owner;1571 const savedToOwner = isOAuth && !automaticOAuthActive ? oauthConnectionOwner : owner;
1525 const showSavedToPicker = !oauthRegistering && savedToOptions.length > 1;1572 const showSavedToPicker = !oauthRegistering && savedToOptions.length > 1;
1526 const callableName = connectionNameFrom(label, savedToOwner, integrationName, organizationId);1573 // OAuth mints a NEW connection per connect, so its preview shows the
1574 // uniquified name (`personalGmail2`); credential saves keep the plain
1575 // derivation (they surface an explicit overwrite through the same name).
1576 const callableName = isOAuth
1577 ? previewConnectionName(label, savedToOwner)
1578 : connectionNameFrom(label, savedToOwner, integrationName, organizationId);
1527 const authStepLabel = isOAuth ? (cimdActive ? "OAuth setup" : "OAuth app") : "Credential";1579 const authStepLabel = isOAuth ? (cimdActive ? "OAuth setup" : "OAuth app") : "Credential";
1528 1580
1529 // Build the picker row's Edit/Remove menu for an app, but only once its full1581 // Build the picker row's Edit/Remove menu for an app, but only once its full
@@ -1666,7 +1718,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 136 unmodified lines
1666 const probeAndAutoNameOAuthConnection = async (1718 const probeAndAutoNameOAuthConnection = async (
1667 connection: OAuthCompletionPayload,1719 connection: OAuthCompletionPayload,
1668 typedLabel: string,1720 typedLabel: string,
1669 defaultIdentityLabel: string,
1670 ): Promise<void> => {1721 ): Promise<void> => {
1671 const check = await doCheckConnectionHealth({1722 const check = await doCheckConnectionHealth({
1672 params: {1723 params: {
@@ -1682,7 +1733,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 9 unmodified lines
1682 result: check.value,1733 result: check.value,
1683 typedLabel,1734 typedLabel,
1684 storedIdentityLabel: connection.identityLabel,1735 storedIdentityLabel: connection.identityLabel,
1685 defaultIdentityLabel,
1686 });1736 });
1687 if (nextIdentityLabel === null) return;1737 if (nextIdentityLabel === null) return;
1688 const updated = await doUpdateConnection({1738 const updated = await doUpdateConnection({
@@ -1881,20 +1931,21 @@ function AddAccountModalView(props: AddAccountModalProps) { · 192 unmodified lines
1881 // backend resolves the app own→shared from the slug, so the payload carries1931 // backend resolves the app own→shared from the slug, so the payload carries
1882 // only the host-resolved connection owner.1932 // only the host-resolved connection owner.
1883 const connectionOwner = oauthConnectionOwner;1933 const connectionOwner = oauthConnectionOwner;
1884 const identityLabel = connectionLabelForHost(1934 // Untyped connects carry NO identity label: the server fills it from the
1885 label,1935 // provider's OIDC claims (the account email), and the fallback probe below
1886 connectionOwner,1936 // covers providers without an id_token. Sending the "<owner> <integration>"
1887 integrationName,1937 // default here would both bury the real account identity and clobber a
1888 organizationId,1938 // curated label on a same-name reconnect.
1889 );1939 const identityLabel = typedIdentityLabel(label);
1890 const payload = {1940 const payload = {
1891 client: chosenClient.slug,1941 client: chosenClient.slug,
1892 clientOwner: chosenClient.owner,1942 clientOwner: chosenClient.owner,
1893 owner: connectionOwner,1943 owner: connectionOwner,
1894 name: connectionNameFrom(label, connectionOwner, integrationName, organizationId),1944 name: previewConnectionName(label, connectionOwner),
1945 newConnection: true,
1895 integration,1946 integration,
1896 template: method.template,1947 template: method.template,
1897 identityLabel,1948 ...(identityLabel !== undefined ? { identityLabel } : {}),
1898 };1949 };
1899 // client_credentials mints inline (no redirect); authorization_code runs the popup.1950 // client_credentials mints inline (no redirect); authorization_code runs the popup.
1900 if (chosenClient.grant === "client_credentials") {1951 if (chosenClient.grant === "client_credentials") {
@@ -1915,7 +1966,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 14 unmodified lines
1915 return;1966 return;
1916 }1967 }
1917 if (exit.value.status === "connected") {1968 if (exit.value.status === "connected") {
1918 await probeAndAutoNameOAuthConnection(exit.value.connection, label, identityLabel);1969 await probeAndAutoNameOAuthConnection(exit.value.connection, label);
1919 }1970 }
1920 setCcBusy(false);1971 setCcBusy(false);
1921 toast.success("Connection added");1972 toast.success("Connection added");
@@ -1948,7 +1999,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 26 unmodified lines
1948 });1999 });
1949 },2000 },
1950 onSuccess: async (connection: OAuthCompletionPayload) => {2001 onSuccess: async (connection: OAuthCompletionPayload) => {
1951 await probeAndAutoNameOAuthConnection(connection, label, identityLabel);2002 await probeAndAutoNameOAuthConnection(connection, label);
1952 toast.success("Connection added");2003 toast.success("Connection added");
1953 close();2004 close();
1954 },2005 },
@@ -1963,8 +2014,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 8 unmodified lines
1963 return;2014 return;
1964 }2015 }
1965 const cimdOwner = owner;2016 const cimdOwner = owner;
1966 const connectionName = connectionNameFrom(label, cimdOwner, integrationName, organizationId);2017 const connectionName = previewConnectionName(label, cimdOwner);
1967 const identityLabel = connectionLabelForHost(label, cimdOwner, integrationName, organizationId);2018 const identityLabel = typedIdentityLabel(label);
1968 setCimdBusy(true);2019 setCimdBusy(true);
1969 const outcome = await runCimdConnect(2020 const outcome = await runCimdConnect(
1970 {2021 {
@@ -1996,10 +2047,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 25 unmodified lines
1996 name: connectionName,2047 name: connectionName,
1997 integration,2048 integration,
1998 template: method.template,2049 template: method.template,
1999 identityLabel,2050 newConnection: true,
2051 ...(identityLabel !== undefined ? { identityLabel } : {}),
2000 },2052 },
2001 onSuccess: async (connection: OAuthCompletionPayload) => {2053 onSuccess: async (connection: OAuthCompletionPayload) => {
2002 await probeAndAutoNameOAuthConnection(connection, label, identityLabel);2054 await probeAndAutoNameOAuthConnection(connection, label);
2003 toast.success("Connection added");2055 toast.success("Connection added");
2004 close();2056 close();
2005 },2057 },
@@ -2039,8 +2091,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 33 unmodified lines
2039 return;2091 return;
2040 }2092 }
2041 const dcrOwner = owner;2093 const dcrOwner = owner;
2042 const connectionName = connectionNameFrom(label, dcrOwner, integrationName, organizationId);2094 const connectionName = previewConnectionName(label, dcrOwner);
2043 const identityLabel = connectionLabelForHost(label, dcrOwner, integrationName, organizationId);2095 const identityLabel = typedIdentityLabel(label);
2044 setDcrBusy(true);2096 setDcrBusy(true);
2045 const outcome = await runDcrConnect(2097 const outcome = await runDcrConnect(
2046 {2098 {
@@ -2087,10 +2139,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 40 unmodified lines
2087 name: connectionName,2139 name: connectionName,
2088 integration,2140 integration,
2089 template: method.template,2141 template: method.template,
2090 identityLabel,2142 newConnection: true,
2143 ...(identityLabel !== undefined ? { identityLabel } : {}),
2091 },2144 },
2092 onSuccess: async (connection: OAuthCompletionPayload) => {2145 onSuccess: async (connection: OAuthCompletionPayload) => {
2093 await probeAndAutoNameOAuthConnection(connection, label, identityLabel);2146 await probeAndAutoNameOAuthConnection(connection, label);
2094 toast.success("Connection added");2147 toast.success("Connection added");
2095 close();2148 close();
2096 },2149 },
@@ -2699,7 +2752,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 602 unmodified lines
2699 hint={2752 hint={
2700 nameOptions.length > 02753 nameOptions.length > 0
2701 ? "from the account your key returned, or type your own"2754 ? "from the account your key returned, or type your own"
2702 : "how you'll tell accounts apart"2755 : isOAuth
2756 ? "detected from the account you sign in with, or type your own"
2757 : "how you'll tell accounts apart"
2703 }2758 }
2704 htmlFor="connection-name"2759 htmlFor="connection-name"
2705 />2760 />
@@ -2729,12 +2784,11 @@ function AddAccountModalView(props: AddAccountModalProps) { · 23 unmodified lines
2729 ) : (2784 ) : (
2730 <Input2785 <Input
2731 id="connection-name"2786 id="connection-name"
2732 placeholder={connectionLabelForHost(2787 placeholder={
2733 "",2788 isOAuth
2734 owner,2789 ? "you@example.com"
2735 integrationName,2790 : connectionLabelForHost("", owner, integrationName, organizationId)
2736 organizationId,2791 }
2737 )}
2738 value={label}2792 value={label}
2739 onChange={(e: React.ChangeEvent<HTMLInputElement>) => {2793 onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
2740 setLabel(e.target.value);2794 setLabel(e.target.value);
packages/react/src/plugins/oauth-sign-in.tsx+4−0
@@ -69,6 +69,9 @@ export type OAuthStartPayload = { · 68 unmodified lines
69 readonly integration: IntegrationSlug;69 readonly integration: IntegrationSlug;
70 readonly template: AuthTemplateSlug;70 readonly template: AuthTemplateSlug;
71 readonly identityLabel?: string;71 readonly identityLabel?: string;
72 /** Mint a NEW connection: the server resolves a taken `name` to the next
73 * free suffixed one. Omitted on reconnect, which targets the existing row. */
74 readonly newConnection?: boolean;
72 readonly redirectUri?: string;75 readonly redirectUri?: string;
73};76};
74 77
@@ -381,6 +384,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines
381 integration: input.payload.integration,384 integration: input.payload.integration,
382 template: input.payload.template,385 template: input.payload.template,
383 identityLabel: input.payload.identityLabel,386 identityLabel: input.payload.identityLabel,
387 newConnection: input.payload.newConnection,
384 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),388 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
385 },389 },
386 }).then((exit) =>390 }).then((exit) =>