History

Trace first-party OAuth usage (#1633)

Rhys Sullivan committed 2 months ago

Commit deb39f71 parent c4432192 files · +52 −3Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/oauth-first-party.test.ts +46 −3
  2. packages/core/sdk/src/oauth-service.ts +6 −0
packages/core/sdk/src/oauth-first-party.test.ts+46−3
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import type * as Tracer from "effect/Tracer";
3 4
4import {5import {
5 AuthTemplateSlug,6 AuthTemplateSlug,
@@ -29,6 +30,37 @@ const INTEG = IntegrationSlug.make("acme"); · 23 unmodified lines
29const TEMPLATE = AuthTemplateSlug.make("oauth");30const TEMPLATE = AuthTemplateSlug.make("oauth");
30const FIRST_PARTY = firstPartyOAuthClientSlug("acme");31const FIRST_PARTY = firstPartyOAuthClientSlug("acme");
31 32
33const makeRecordingTracer = (spans: Map<string, Map<string, unknown>>): Tracer.Tracer => ({
34 span: (options) => {
35 const attributes = new Map<string, unknown>();
36 spans.set(options.name, attributes);
37 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
38 return {
39 _tag: "Span",
40 name: options.name,
41 spanId: "0000000000000001",
42 traceId: "00000000000000000000000000000001",
43 parent: options.parent,
44 annotations: options.annotations,
45 get status() {
46 return status;
47 },
48 attributes,
49 links: options.links,
50 sampled: options.sampled,
51 kind: options.kind,
52 end: (endTime, exit) => {
53 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
54 },
55 attribute: (key, value) => {
56 attributes.set(key, value);
57 },
58 event: () => undefined,
59 addLinks: () => undefined,
60 };
61 },
62});
63
32const oauthPlugin = definePlugin(() => ({64const oauthPlugin = definePlugin(() => ({
33 id: "acme" as const,65 id: "acme" as const,
34 storage: () => ({}),66 storage: () => ({}),
@@ -81,8 +113,9 @@ const firstPartyClientFor = (server: { · 46 unmodified lines
81describe("first-party oauth clients", () => {113describe("first-party oauth clients", () => {
82 it.effect(114 it.effect(
83 "start → complete through a config-declared client mints an executable connection",115 "start → complete through a config-declared client mints an executable connection",
84 () =>116 () => {
85 Effect.scoped(117 const spans = new Map<string, Map<string, unknown>>();
118 return Effect.scoped(
86 Effect.gen(function* () {119 Effect.gen(function* () {
87 const server = yield* serveOAuthTestServer({ scopes: ["read"] });120 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
88 const { executor } = yield* makeTestWorkspaceHarness({121 const { executor } = yield* makeTestWorkspaceHarness({
@@ -101,6 +134,9 @@ describe("first-party oauth clients", () => { · 12 unmodified lines
101 template: TEMPLATE,134 template: TEMPLATE,
102 });135 });
103 expect(started.status).toBe("redirect");136 expect(started.status).toBe("redirect");
137 expect(
138 spans.get("test.oauth.first_party")?.get("executor.oauth.client_first_party"),
139 ).toBe(true);
104 if (started.status !== "redirect") return;140 if (started.status !== "redirect") return;
105 141
106 const callback = yield* server.completeAuthorizationCodeFlow({142 const callback = yield* server.completeAuthorizationCodeFlow({
@@ -111,6 +147,9 @@ describe("first-party oauth clients", () => { · 4 unmodified lines
111 code: callback.code,147 code: callback.code,
112 });148 });
113 expect(String(connection.address)).toBe("tools.acme.org.mainAccount");149 expect(String(connection.address)).toBe("tools.acme.org.mainAccount");
150 expect(
151 spans.get("executor.oauth.complete")?.get("executor.oauth.client_first_party"),
152 ).toBe(true);
114 153
115 const out = (yield* executor.execute(154 const out = (yield* executor.execute(
116 ToolAddress.make("tools.acme.org.mainAccount.whoami"),155 ToolAddress.make("tools.acme.org.mainAccount.whoami"),
@@ -119,7 +158,11 @@ describe("first-party oauth clients", () => { · 2 unmodified lines
119 expect(out.token).toMatch(/^at_/);158 expect(out.token).toMatch(/^at_/);
120 expect(yield* server.acceptsAccessToken(out.token)).toBe(true);159 expect(yield* server.acceptsAccessToken(out.token)).toBe(true);
121 }),160 }),
122 ),161 ).pipe(
162 Effect.withSpan("test.oauth.first_party"),
163 Effect.withTracer(makeRecordingTracer(spans)),
164 );
165 },
123 );166 );
124 167
125 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>168 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
packages/core/sdk/src/oauth-service.ts+6−0
@@ -1152,6 +1152,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1151 unmodified lines
1152 // First-party apps are deployment-owned, outside the owner lattice1152 // First-party apps are deployment-owned, outside the owner lattice
1153 // entirely, so the rule does not apply to them.1153 // entirely, so the rule does not apply to them.
1154 const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client));1154 const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client));
1155 yield* Effect.annotateCurrentSpan({
1156 "executor.oauth.client_first_party": firstPartyFlow,
1157 });
1155 if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") {1158 if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") {
1156 return yield* new OAuthStartError({1159 return yield* new OAuthStartError({
1157 message: "A Workspace connection must use a Workspace app.",1160 message: "A Workspace connection must use a Workspace app.",
@@ -1404,6 +1407,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 246 unmodified lines
1404 "executor.connection": String(session.name),1407 "executor.connection": String(session.name),
1405 "executor.template": String(session.template),1408 "executor.template": String(session.template),
1406 "executor.oauth.client": String(session.clientSlug),1409 "executor.oauth.client": String(session.clientSlug),
1410 "executor.oauth.client_first_party": isFirstPartyOAuthClientSlug(
1411 String(session.clientSlug),
1412 ),
1407 });1413 });
1408 1414
1409 // Expired sessions are not redeemable — drop + treat as not found.1415 // Expired sessions are not redeemable — drop + treat as not found.