Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion providers/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion providers/linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
10 changes: 5 additions & 5 deletions specs/linear-adapter.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/core/external-authorization-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
})
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions src/core/realmroot-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface DpopReplayStore {
claim(input: { keyThumbprint: string; jti: string; expiresAt: number; now: number }): Promise<boolean>
}

const realmrootCliClientId = 'realmroot-cli'

export function createRealmrootAuthenticator(input: {
issuer: string
jwksUrl?: string
Expand Down Expand Up @@ -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.')
}
Expand Down
24 changes: 8 additions & 16 deletions src/providers/linear/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand All @@ -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
Expand Down Expand Up @@ -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([
Expand Down
4 changes: 2 additions & 2 deletions src/providers/linear/external-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
Expand Down
44 changes: 43 additions & 1 deletion test/core/realmroot-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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' }
Expand Down
42 changes: 31 additions & 11 deletions test/integration/linear-connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?`,
Expand Down
67 changes: 67 additions & 0 deletions test/providers/linear-external-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
})
})