History

Record originIntegration on manual OAuth clients

A manual OAuth app registered from within an integration's connect dialog now stamps that integration on origin_integration (kind stays manual), mirroring how DCR clients already record the integration that requested them. Thread it through the createClient wire contract (HTTP API + core tool), persist it for both manual and DCR origins, and have parseOAuthClientOrigin surface it on the manual summary so the picker can match a bring-your-own app to its integration by recorded intent rather than guessing by root domain.

Rhys Sullivan committed 4 months ago

Commit 7b4a85b1 parent 8155ab27 files · +37 −8Browse files

Unified · Split

7 files
  1. packages/core/api/src/handlers/oauth.ts +1 −0
  2. packages/core/api/src/oauth/api.ts +3 −0
  3. packages/core/sdk/src/core-tools.ts +10 −0
  4. packages/core/sdk/src/oauth-client.ts +9 −1
  5. packages/core/sdk/src/oauth-list-clients.test.ts +3 −1
  6. packages/core/sdk/src/oauth-service.ts +10 −6
  7. packages/core/sdk/src/shared.ts +1 −0
packages/core/api/src/handlers/oauth.ts+1−0
@@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines
103 clientId: payload.clientId,103 clientId: payload.clientId,
104 clientSecret: payload.clientSecret,104 clientSecret: payload.clientSecret,
105 resource: payload.resource ?? null,105 resource: payload.resource ?? null,
106 origin: { kind: "manual", integration: payload.originIntegration ?? null },
106 });107 });
107 return { client };108 return { client };
108 }),109 }),
packages/core/api/src/oauth/api.ts+3−0
@@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines
65 clientId: Schema.String,65 clientId: Schema.String,
66 clientSecret: Schema.String,66 clientSecret: Schema.String,
67 resource: Schema.optional(Schema.NullOr(Schema.String)),67 resource: Schema.optional(Schema.NullOr(Schema.String)),
68 /** Integration whose connect dialog registered this manual app. Recorded so
69 * the picker matches it to this integration by intent, not root domain. */
70 originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)),
68});71});
69 72
70const CreateClientResponse = Schema.Struct({73const CreateClientResponse = Schema.Struct({
packages/core/sdk/src/core-tools.ts+10−0
@@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines
243 grant: OAuthGrantSchema,243 grant: OAuthGrantSchema,
244 clientId: Schema.String,244 clientId: Schema.String,
245 resource: Schema.optional(Schema.NullOr(Schema.String)),245 resource: Schema.optional(Schema.NullOr(Schema.String)),
246 /** Integration whose connect dialog registered this manual app. Recorded so
247 * the picker can match it by intent (exact) instead of by root domain. */
248 originIntegration: Schema.optional(Schema.NullOr(Schema.String)),
246});249});
247// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret250// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret
248// fields the form pre-fills. The client secret is typed by the human in the web251// fields the form pre-fills. The client secret is typed by the human in the web
@@ -713,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 464 unmodified lines
713 // `oauth.clients.createHandoff`.716 // `oauth.clients.createHandoff`.
714 clientSecret: "",717 clientSecret: "",
715 resource: input.resource ?? null,718 resource: input.resource ?? null,
719 origin: {
720 kind: "manual",
721 integration:
722 input.originIntegration == null
723 ? null
724 : IntegrationSlug.make(input.originIntegration),
725 },
716 }),726 }),
717 (client) => ({ client: String(client) }),727 (client) => ({ client: String(client) }),
718 ),728 ),
packages/core/sdk/src/oauth-client.ts+9−1
@@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines
64}64}
65 65
66export type OAuthClientOrigin =66export type OAuthClientOrigin =
67 | { readonly kind: "manual" }67 | {
68 readonly kind: "manual";
69 /** Integration whose connect dialog registered this manual app, when the
70 * registration happened from within one. Lets the picker match a BYO app
71 * to its integration by recorded intent (exact) instead of guessing by
72 * root domain. Null for apps registered outside any integration context
73 * (and for legacy rows predating the stamp). */
74 readonly integration?: IntegrationSlug | null;
75 }
68 | {76 | {
69 readonly kind: "dynamic_client_registration";77 readonly kind: "dynamic_client_registration";
70 readonly integration?: IntegrationSlug | null;78 readonly integration?: IntegrationSlug | null;
packages/core/sdk/src/oauth-list-clients.test.ts+3−1
@@ -91,7 +91,9 @@ describe("oauth.listClients", () => { · 90 unmodified lines
91 tokenUrl: "https://acme.test/token",91 tokenUrl: "https://acme.test/token",
92 resource: null,92 resource: null,
93 clientId: "org-client-id",93 clientId: "org-client-id",
94 origin: { kind: "manual" },94 // Manual apps carry a nullable recorded-intent integration; a client
95 // created outside any integration dialog stamps null.
96 origin: { kind: "manual", integration: null },
95 });97 });
96 expect(user!.owner).toBe("user");98 expect(user!.owner).toBe("user");
97 expect(user!.grant).toBe("client_credentials");99 expect(user!.grant).toBe("client_credentials");
packages/core/sdk/src/oauth-service.ts+10−6
@@ -367,7 +367,11 @@ const parseOAuthClientOrigin = (row: { · 366 unmodified lines
367 ) {367 ) {
368 return { kind: "dynamic_client_registration", integration: null };368 return { kind: "dynamic_client_registration", integration: null };
369 }369 }
370 return { kind: "manual" };370 return {
371 kind: "manual",
372 integration:
373 row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration)),
374 };
371};375};
372 376
373interface LoadedOAuthClient {377interface LoadedOAuthClient {
@@ -614,12 +618,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines
614 client_secret_item_id: clientSecretItemIdValue,618 client_secret_item_id: clientSecretItemIdValue,
615 resource: input.resource ?? null,619 resource: input.resource ?? null,
616 origin_kind: input.origin?.kind ?? "manual",620 origin_kind: input.origin?.kind ?? "manual",
621 // Recorded intent, kept for BOTH origins: a manual app registered from
622 // an integration's dialog stamps its integration so the picker can
623 // match it exactly, the same way a DCR client records the integration
624 // that requested it.
617 origin_integration:625 origin_integration:
618 input.origin?.kind === "dynamic_client_registration"626 input.origin?.integration == null ? null : String(input.origin.integration),
619 ? input.origin.integration == null
620 ? null
621 : String(input.origin.integration)
622 : null,
623 origin_issuer:627 origin_issuer:
624 input.origin?.kind === "dynamic_client_registration"628 input.origin?.kind === "dynamic_client_registration"
625 ? (canonicalIssuerUrl(input.originIssuer) ?? null)629 ? (canonicalIssuerUrl(input.originIssuer) ?? null)
packages/core/sdk/src/shared.ts+1−0
@@ -110,6 +110,7 @@ export { · 109 unmodified lines
110 type OAuthGrant,110 type OAuthGrant,
111 type OAuthAuthentication,111 type OAuthAuthentication,
112 type OAuthClient,112 type OAuthClient,
113 type OAuthClientOrigin,
113 type OAuthClientSummary,114 type OAuthClientSummary,
114 type CreateOAuthClientInput,115 type CreateOAuthClientInput,
115 type RegisterDynamicClientInput,116 type RegisterDynamicClientInput,