From 5dbb548c62779459bc54a7ba47da320ec1619807 Mon Sep 17 00:00:00 2001 From: jarvis Date: Tue, 18 Aug 2026 10:31:42 -0400 Subject: [PATCH 1/2] fix(linear): key authorizations by user identity Preserve a stable Linear user identity while retaining installed workspaces as provider-owned contexts. --- specs/linear-adapter.feature | 10 +-- src/providers/linear/connections.ts | 24 +++---- .../linear/external-authorization.ts | 4 +- test/integration/linear-connections.test.ts | 42 +++++++++--- .../linear-external-authorization.test.ts | 67 +++++++++++++++++++ 5 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 test/providers/linear-external-authorization.test.ts diff --git a/specs/linear-adapter.feature b/specs/linear-adapter.feature index e9dd868..ba8be61 100644 --- a/specs/linear-adapter.feature +++ b/specs/linear-adapter.feature @@ -14,20 +14,20 @@ Feature: Linear Agent adapter Scenario: One external authorization identifies the user and installs the App Given a Realmroot owner starts Linear authorization through the Adapter When the owner authorizes their Linear user and then installs the App with actor app - Then the adapter exposes the installed workspace as the external authorization subject - And the Provider Connection selects that workspace without an authorization detail or Context + Then the adapter exposes the Linear user as the external authorization subject + And the Provider Connection retains installed workspaces as provider-owned contexts And provider credentials remain encrypted outside Realmroot and the Agent @journey:linear-workspace-reauthorization @entrypoint:http Scenario: Reauthorization refreshes or replaces the selected workspace Given a Realmroot owner already authorized one Linear workspace When the owner refreshes that workspace or connects another workspace - Then the adapter exposes exactly one workspace through the Provider Connection - And Realmroot replaces the old external authorization instead of creating another Context + Then the adapter preserves one Linear user through the Provider Connection + And it refreshes the provider-owned workspace contexts without replacing that identity @journey:linear-transparent-graphql @entrypoint:http Scenario: An authorized Agent calls the original Linear GraphQL API - Given the Agent token subject identifies the connected Linear workspace + Given the Agent token subject identifies the connected Linear user And the token contains the official Linear scopes required by the selected GraphQL operation When the Agent posts the original GraphQL document and variables through the adapter Then the adapter forwards the GraphQL transport to Linear without inventing REST business endpoints diff --git a/src/providers/linear/connections.ts b/src/providers/linear/connections.ts index 41bbbe2..d6e097b 100644 --- a/src/providers/linear/connections.ts +++ b/src/providers/linear/connections.ts @@ -81,7 +81,7 @@ export class D1LinearConnections implements LinearConnectionStore { async upsertExternalAuthorization(linearUser: LinearViewer['user'], viewer: LinearViewer, token: LinearToken) { const now = Date.now() - const brokerReference = `linear:${viewer.workspace.id}` + const brokerReference = `linear:${linearUser.id}` const context = credentialContext(brokerReference, viewer.workspace.id) const [accessToken, refreshToken] = await Promise.all([ this.cipher.seal(token.accessToken, `${context}:access`), @@ -96,15 +96,7 @@ export class D1LinearConnections implements LinearConnectionStore { ON CONFLICT(broker_reference) DO UPDATE SET display_name = excluded.display_name, scopes_json = excluded.scopes_json, status = 'active', updated_at = excluded.updated_at`, ) - .bind( - brokerReference, - viewer.workspace.id, - linearUser.id, - viewer.workspace.name, - JSON.stringify(token.scopes), - now, - now, - ), + .bind(brokerReference, linearUser.id, linearUser.id, linearUser.name, JSON.stringify(token.scopes), now, now), this.db .prepare( `UPDATE linear_connection_context @@ -154,39 +146,39 @@ export class D1LinearConnections implements LinearConnectionStore { return this.contexts(brokerReference) } - async externalAuthorization(workspaceId: string) { + async externalAuthorization(ownerSubject: string) { const binding = await this.db .prepare( `SELECT broker_reference AS brokerReference, display_name AS displayName FROM linear_connection_binding WHERE owner_subject = ? AND status = 'active'`, ) - .bind(workspaceId) + .bind(ownerSubject) .first<{ brokerReference: string; displayName: string }>() if (!binding) throw forbidden('Active Linear authorization is required.') return { displayName: binding.displayName, contexts: await this.contexts(binding.brokerReference) } } - async externalCredentials(workspaceId: string) { + async externalCredentials(ownerSubject: string) { const binding = await this.db .prepare( `SELECT broker_reference AS brokerReference FROM linear_connection_binding WHERE owner_subject = ? AND status = 'active'`, ) - .bind(workspaceId) + .bind(ownerSubject) .first<{ brokerReference: string }>() if (!binding) return [] return Promise.all((await this.credentialRows(binding.brokerReference)).map((row) => this.decryptCredential(row))) } - async revokeExternalAuthorization(workspaceId: string) { + async revokeExternalAuthorization(ownerSubject: string) { const now = Date.now() const binding = await this.db .prepare( `SELECT broker_reference AS brokerReference FROM linear_connection_binding WHERE owner_subject = ? AND status = 'active'`, ) - .bind(workspaceId) + .bind(ownerSubject) .first<{ brokerReference: string }>() if (!binding) return await this.db.batch([ diff --git a/src/providers/linear/external-authorization.ts b/src/providers/linear/external-authorization.ts index 2d052e5..98ddc85 100644 --- a/src/providers/linear/external-authorization.ts +++ b/src/providers/linear/external-authorization.ts @@ -55,8 +55,8 @@ export function createLinearExternalAuthorization(input: { return { type: 'complete', grant: { - subject: workspace.workspaceId, - displayName: workspace.workspaceName, + subject: linearUser.id, + displayName: linearUser.name, scopes: intent.scopes, authorizationDetails: [], }, diff --git a/test/integration/linear-connections.test.ts b/test/integration/linear-connections.test.ts index 0f84913..9b2baea 100644 --- a/test/integration/linear-connections.test.ts +++ b/test/integration/linear-connections.test.ts @@ -62,25 +62,45 @@ describe('Linear connection persistence', () => { ) }) - it('replaces a legacy brokered workspace when external authorization is established', async () => { + it('[spec: linear-adapter/linear-provider-connection] keys external authorization by Linear user while retaining workspace contexts', async () => { const store = new D1LinearConnections(env.DB, cipher, state) await connectWorkspace(store, 'legacy-owner', 'legacy', 'legacy-workspace', 'legacy-user') - const contexts = await store.upsertExternalAuthorization( + await store.upsertExternalAuthorization( humanViewer('legacy-user').user, appViewer('legacy-workspace', 'external-app-user'), token('external-access', 'external-refresh'), ) + const contexts = await store.upsertExternalAuthorization( + humanViewer('legacy-user').user, + appViewer('second-workspace', 'second-app-user'), + token('second-access', 'second-refresh'), + ) - expect(contexts.map((context) => context.workspaceId)).toEqual(['legacy-workspace']) - await expect(store.externalCredentials('legacy-workspace')).resolves.toMatchObject([ - { - brokerReference: 'linear:legacy-workspace', - workspaceId: 'legacy-workspace', - accessToken: 'external-access', - refreshToken: 'external-refresh', - }, - ]) + expect(contexts.map((context) => context.workspaceId).sort()).toEqual(['legacy-workspace', 'second-workspace']) + await expect(store.externalAuthorization('legacy-user')).resolves.toMatchObject({ + displayName: 'Jasper Van', + contexts: expect.arrayContaining([ + expect.objectContaining({ workspaceId: 'legacy-workspace' }), + expect.objectContaining({ workspaceId: 'second-workspace' }), + ]), + }) + await expect(store.externalCredentials('legacy-user')).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + brokerReference: 'linear:legacy-user', + workspaceId: 'legacy-workspace', + accessToken: 'external-access', + refreshToken: 'external-refresh', + }), + expect.objectContaining({ + brokerReference: 'linear:legacy-user', + workspaceId: 'second-workspace', + accessToken: 'second-access', + refreshToken: 'second-refresh', + }), + ]), + ) const legacy = await env.DB.prepare( `SELECT status, access_token_ciphertext AS accessToken, refresh_token_ciphertext AS refreshToken FROM linear_connection_context WHERE broker_reference = ? AND workspace_id = ?`, diff --git a/test/providers/linear-external-authorization.test.ts b/test/providers/linear-external-authorization.test.ts new file mode 100644 index 0000000..f1422d2 --- /dev/null +++ b/test/providers/linear-external-authorization.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExternalOAuthIntent } from '../../src/core/external-oauth-store.js' +import type { D1LinearConnections } from '../../src/providers/linear/connections.js' +import { createLinearExternalAuthorization } from '../../src/providers/linear/external-authorization.js' +import type { LinearProvider, LinearToken, LinearViewer } from '../../src/providers/linear/types.js' + +describe('Linear external authorization', () => { + it('[spec: linear-adapter/linear-provider-connection] exposes the Linear user as the stable Provider Connection subject', async () => { + const token: LinearToken = { + accessToken: 'linear-access', + refreshToken: 'linear-refresh', + expiresAt: Date.now() + 60_000, + scopes: ['read'], + } + const viewer: LinearViewer = { + user: { id: 'linear-user-1', name: 'Jasper', email: 'jasper@example.com' }, + workspace: { id: 'workspace-1', name: 'Realmroot', urlKey: 'realmroot', logoUrl: null }, + } + const provider = { + exchangeCode: vi.fn(async () => token), + viewer: vi.fn(async () => viewer), + } as unknown as LinearProvider + const connections = { + upsertExternalAuthorization: vi.fn(async () => [ + { + workspaceId: viewer.workspace.id, + workspaceName: viewer.workspace.name, + workspaceUrlKey: viewer.workspace.urlKey, + appUserId: 'linear-app-user-1', + }, + ]), + } as unknown as D1LinearConnections + const authorization = createLinearExternalAuthorization({ + origin: 'https://adapter.example', + provider, + connections, + scopes: ['read'], + }) + const intent: ExternalOAuthIntent = { + id: 'intent-1', + providerId: 'linear', + clientId: 'realmroot', + redirectUri: 'https://id.example/callback', + realmrootState: 'realmroot-state', + scopes: ['openid', 'offline_access', 'read'], + authorizationDetails: [], + codeChallenge: 'challenge', + providerStage: 'app', + providerData: { linearUser: viewer.user, requestedScopes: ['read'] }, + expiresAt: Date.now() + 60_000, + } + + await expect( + authorization.complete({ + callbackUrl: 'https://adapter.example/linear/oauth/callback?code=linear-code', + intent, + nextProviderState: () => 'unused', + }), + ).resolves.toMatchObject({ + type: 'complete', + grant: { + subject: 'linear-user-1', + displayName: 'Jasper', + }, + }) + }) +}) From 686032736b828a0e1d028756497efee11aaa5df4 Mon Sep 17 00:00:00 2001 From: jarvis Date: Tue, 18 Aug 2026 10:31:49 -0400 Subject: [PATCH 2/2] fix(auth): validate standardized Agent tokens Require the fixed Realmroot CLI client identifier and replace retired Agent claim checks across adapter authentication and documentation. --- providers/github/README.md | 2 +- providers/linear/README.md | 2 +- src/core/external-authorization-server.ts | 6 ++-- src/core/realmroot-auth.ts | 7 ++-- test/core/realmroot-auth.test.ts | 44 ++++++++++++++++++++++- 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/providers/github/README.md b/providers/github/README.md index c3b8341..618460c 100644 --- a/providers/github/README.md +++ b/providers/github/README.md @@ -101,7 +101,7 @@ mean GitHub implements that capability natively. | `CLIENT-REGISTRATION` | โž– | The selected GitHub App is preregistered. | | `CLIENT-MANAGEMENT` | โž– | Dynamic client registration is not selected. | | `ACTOR-CHAIN` | ๐ŸŸจ | The adapter can preserve the Realmroot actor in its audit chain, but GitHub receives a GitHub credential. | -| `ACTOR-PROFILE` | ๐ŸŸจ | The adapter validates `ai_agent`; GitHub does not consume that actor profile. | +| `ACTOR-PROFILE` | ๐ŸŸจ | The adapter validates the Realmroot `act` issuer and subject; GitHub does not consume that actor classification. | | `ACTOR-NATIVE` | โŒ | GitHub attributes installation calls to the App and user-token calls to the user plus App, not to the originating Realmroot Agent. | | `AGENT-DISPLAY` | โŒ | A footer or adapter-side record is not provider-native Agent display. | | `ACTOR-ASSERTION` | ๐ŸŸจ | GitHub App JWT authentication is provider-specific and is not the RFC 7523 Agent assertion grant required by this profile. | diff --git a/providers/linear/README.md b/providers/linear/README.md index 4a7567d..44df1a2 100644 --- a/providers/linear/README.md +++ b/providers/linear/README.md @@ -115,7 +115,7 @@ claiming that the external Realmroot Agent is the Linear security principal. | `CLIENT-REGISTRATION` | โž– | The Linear OAuth App is preregistered. | | `CLIENT-MANAGEMENT` | โž– | Dynamic client registration is not selected. | | `ACTOR-CHAIN` | ๐ŸŸจ | The adapter preserves Realmroot actor context, but Linear receives an App actor token. | -| `ACTOR-PROFILE` | ๐ŸŸจ | The adapter validates `ai_agent`; Linear does not consume that profile. | +| `ACTOR-PROFILE` | ๐ŸŸจ | The adapter validates the Realmroot `act` issuer and subject; Linear does not consume that actor classification. | | `ACTOR-NATIVE` | ๐Ÿงช | `actor=app` creates one provider-native App user per workspace, but every Realmroot Agent shares it and the stable external Agent identifier is not the token principal. | | `AGENT-DISPLAY` | ๐Ÿงช | Trusted adapter-supplied `createAsUser` and `displayIconUrl` render the originating Agent without a footer; the fields are operation display metadata, not identity proof. | | `ACTOR-ASSERTION` | ๐ŸŸจ | Linear does not document the RFC 7523 Agent assertion grant required by the profile. | diff --git a/src/core/external-authorization-server.ts b/src/core/external-authorization-server.ts index e912689..8f5db96 100644 --- a/src/core/external-authorization-server.ts +++ b/src/core/external-authorization-server.ts @@ -350,7 +350,6 @@ export async function createExternalAuthorizationServer(input: { act: { iss: actor.payload.agent_iss, sub: actor.payload.sub, - sub_profile: 'ai_agent', }, cnf: { jkt: proof.jkt }, }) @@ -432,12 +431,11 @@ export async function createExternalAuthorizationServer(input: { const proof = await verifyDpop(request, dpopTargetUri(request.url), input.replayStore, token) const confirmation = verified.payload.cnf as { jkt?: unknown } | undefined if (confirmation?.jkt !== proof.jkt) throw oauthError('invalid_token', 'DPoP key does not match.', 401) - const actor = verified.payload.act as { iss?: unknown; sub?: unknown; sub_profile?: unknown } | undefined + const actor = verified.payload.act as { iss?: unknown; sub?: unknown } | undefined if ( typeof verified.payload.sub !== 'string' || typeof actor?.iss !== 'string' || - typeof actor.sub !== 'string' || - actor.sub_profile !== 'ai_agent' + typeof actor.sub !== 'string' ) { throw oauthError('invalid_token', 'Token does not identify an Agent.', 401) } diff --git a/src/core/realmroot-auth.ts b/src/core/realmroot-auth.ts index 5d495a5..befece9 100644 --- a/src/core/realmroot-auth.ts +++ b/src/core/realmroot-auth.ts @@ -28,6 +28,8 @@ export interface DpopReplayStore { claim(input: { keyThumbprint: string; jti: string; expiresAt: number; now: number }): Promise } +const realmrootCliClientId = 'realmroot-cli' + export function createRealmrootAuthenticator(input: { issuer: string jwksUrl?: string @@ -56,12 +58,13 @@ export function createRealmrootAuthenticator(input: { const confirmation = access.payload.cnf as { jkt?: unknown } | undefined if (confirmation?.jkt !== proof.jkt) throw unauthorized('The DPoP key does not match the access token.') - const actor = access.payload.act as { iss?: unknown; sub?: unknown; sub_profile?: unknown } | undefined + const actor = access.payload.act as { iss?: unknown; sub?: unknown } | undefined if ( typeof access.payload.sub !== 'string' || + access.payload.client_id !== realmrootCliClientId || typeof actor?.iss !== 'string' || typeof actor.sub !== 'string' || - actor.sub_profile !== 'ai_agent' + actor.iss !== input.issuer ) { throw unauthorized('The access token does not identify a Realmroot Agent.') } diff --git a/test/core/realmroot-auth.test.ts b/test/core/realmroot-auth.test.ts index 25372a0..2b33d93 100644 --- a/test/core/realmroot-auth.test.ts +++ b/test/core/realmroot-auth.test.ts @@ -14,8 +14,9 @@ describe('Realmroot DPoP authentication', () => { const now = 1_800_000_000_000 const token = await new SignJWT({ scope: 'github:metadata:read', + client_id: 'realmroot-cli', cnf: { jkt: await calculateJwkThumbprint(dpopJwk) }, - act: { iss: issuer, sub: 'agt_1', sub_profile: 'ai_agent' }, + act: { iss: issuer, sub: 'agt_1' }, }) .setProtectedHeader({ alg: 'RS256', typ: 'at+jwt', kid: 'access-1' }) .setIssuer(issuer) @@ -50,6 +51,47 @@ describe('Realmroot DPoP authentication', () => { await expect(authenticator.authenticate(request, audience)).rejects.toThrow('already used') }) + it('rejects an Agent token issued to a different client', async () => { + const issuer = 'https://id.example/api/auth' + const audience = 'https://adapter.example/github/installations/42' + const accessKeys = await generateKeyPair('RS256') + const dpopKeys = await generateKeyPair('ES256') + const accessJwk = { ...(await exportJWK(accessKeys.publicKey)), kid: 'access-1', alg: 'RS256' } + const dpopJwk = await exportJWK(dpopKeys.publicKey) + const now = 1_800_000_000_000 + const token = await new SignJWT({ + client_id: 'another-client', + cnf: { jkt: await calculateJwkThumbprint(dpopJwk) }, + act: { iss: issuer, sub: 'agt_1' }, + }) + .setProtectedHeader({ alg: 'RS256', typ: 'at+jwt', kid: 'access-1' }) + .setIssuer(issuer) + .setSubject('org_1') + .setAudience(audience) + .setIssuedAt(now / 1000) + .setExpirationTime(now / 1000 + 300) + .sign(accessKeys.privateKey) + const targetUrl = `${audience}/repositories` + const proof = await new SignJWT({ htu: targetUrl, htm: 'GET', ath: await sha256Base64Url(token) }) + .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: dpopJwk }) + .setIssuedAt(now / 1000) + .setJti('wrong-client-proof') + .sign(dpopKeys.privateKey) + const authenticator = createRealmrootAuthenticator({ + issuer, + jwks: { keys: [accessJwk] }, + replayStore: replayStore(), + now: () => now, + }) + + await expect( + authenticator.authenticate( + new Request(targetUrl, { headers: { authorization: `DPoP ${token}`, dpop: proof } }), + audience, + ), + ).rejects.toThrow('does not identify a Realmroot Agent') + }) + it('rejects missing and invalid Realmroot credentials before authorization', async () => { const accessKeys = await generateKeyPair('RS256') const accessJwk = { ...(await exportJWK(accessKeys.publicKey)), kid: 'access-1', alg: 'RS256' }