The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 278de6001cb3Commit detailsView latest

api/oauth-store.ts 295 lines · 10.0 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { createHash, randomBytes } from 'node:crypto';
3
import { canonicalize } from 'json-canonicalize';
4
5
export type OAuthClient = {
6
  clientId: string;
7
  redirectUris: string[];
8
  clientName?: string;
9
};
10
11
export type OAuthTransaction = {
12
  transactionId: string;
13
  clientId: string;
14
  redirectUri: string;
15
  downstreamState?: string;
16
  scope: string;
17
  codeChallenge: string;
18
  auth0Verifier: string;
19
};
20
21
export type OAuthAuthorizationCode = {
22
  clientId: string;
23
  operatorId: string;
24
  redirectUri: string;
25
  scope: string;
26
  codeChallenge: string;
27
};
28
29
export type OAuthAccessToken = {
30
  clientId: string;
31
  operatorId: string;
32
  scopes: string[];
33
  expiresAt: string;
34
};
35
36
const digest = (value: string) => createHash('sha256').update(value).digest('base64url');
37
export const randomOAuthValue = (prefix: string) =>
38
  `${prefix}${randomBytes(32).toString('base64url')}`;
39
40
export class OAuthStore {
41
  private readonly sql: NeonQueryFunction<false, false>;
42
  private initialized?: Promise<void>;
43
44
  constructor(databaseUrl: string) {
45
    this.sql = neon(databaseUrl);
46
  }
47
48
  ensureSchema(): Promise<void> {
49
    return (this.initialized ??= (async () => {
50
      await this.sql`
51
        CREATE TABLE IF NOT EXISTS openclasp_oauth_clients (
52
          client_id TEXT PRIMARY KEY,
53
          client_name TEXT,
54
          redirect_uris JSONB NOT NULL,
55
          metadata JSONB NOT NULL,
56
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
57
        )
58
      `;
59
      await this.sql`
60
        CREATE TABLE IF NOT EXISTS openclasp_oauth_transactions (
61
          transaction_id TEXT PRIMARY KEY,
62
          client_id TEXT NOT NULL REFERENCES openclasp_oauth_clients(client_id) ON DELETE CASCADE,
63
          redirect_uri TEXT NOT NULL,
64
          downstream_state TEXT,
65
          scope TEXT NOT NULL,
66
          code_challenge TEXT NOT NULL,
67
          auth0_verifier TEXT NOT NULL,
68
          expires_at TIMESTAMPTZ NOT NULL,
69
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
70
        )
71
      `;
72
      await this.sql`
73
        CREATE TABLE IF NOT EXISTS openclasp_oauth_codes (
74
          code_hash TEXT PRIMARY KEY,
75
          client_id TEXT NOT NULL REFERENCES openclasp_oauth_clients(client_id) ON DELETE CASCADE,
76
          operator_id TEXT NOT NULL,
77
          redirect_uri TEXT NOT NULL,
78
          scope TEXT NOT NULL,
79
          code_challenge TEXT NOT NULL,
80
          expires_at TIMESTAMPTZ NOT NULL,
81
          used_at TIMESTAMPTZ,
82
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
83
        )
84
      `;
85
      await this.sql`
86
        CREATE TABLE IF NOT EXISTS openclasp_oauth_access_tokens (
87
          token_hash TEXT PRIMARY KEY,
88
          client_id TEXT NOT NULL REFERENCES openclasp_oauth_clients(client_id) ON DELETE CASCADE,
89
          operator_id TEXT NOT NULL,
90
          scopes JSONB NOT NULL,
91
          expires_at TIMESTAMPTZ NOT NULL,
92
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
93
        )
94
      `;
95
      await this.sql`
96
        CREATE TABLE IF NOT EXISTS openclasp_oauth_refresh_tokens (
97
          token_hash TEXT PRIMARY KEY,
98
          client_id TEXT NOT NULL REFERENCES openclasp_oauth_clients(client_id) ON DELETE CASCADE,
99
          operator_id TEXT NOT NULL,
100
          scopes JSONB NOT NULL,
101
          expires_at TIMESTAMPTZ NOT NULL,
102
          revoked_at TIMESTAMPTZ,
103
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
104
        )
105
      `;
106
    })());
107
  }
108
109
  async registerClient(metadata: Record<string, unknown>): Promise<OAuthClient> {
110
    await this.ensureSchema();
111
    const canonical = canonicalize(metadata);
112
    const clientId = `oc_${digest(canonical).slice(0, 40)}`;
113
    const redirectUris = metadata.redirect_uris as string[];
114
    const clientName = typeof metadata.client_name === 'string' ? metadata.client_name : undefined;
115
    await this.sql`
116
      INSERT INTO openclasp_oauth_clients(client_id, client_name, redirect_uris, metadata)
117
      VALUES (${clientId}, ${clientName ?? null}, ${JSON.stringify(redirectUris)}::jsonb,
118
        ${JSON.stringify(metadata)}::jsonb)
119
      ON CONFLICT (client_id) DO NOTHING
120
    `;
121
    return { clientId, redirectUris, ...(clientName ? { clientName } : {}) };
122
  }
123
124
  async getClient(clientId: string): Promise<OAuthClient | undefined> {
125
    await this.ensureSchema();
126
    const rows = await this.sql`
127
      SELECT client_id, client_name, redirect_uris
128
      FROM openclasp_oauth_clients WHERE client_id = ${clientId} LIMIT 1
129
    `;
130
    const row = rows[0];
131
    if (!row) return undefined;
132
    return {
133
      clientId: String(row.client_id),
134
      redirectUris: row.redirect_uris as string[],
135
      ...(typeof row.client_name === 'string' ? { clientName: row.client_name } : {}),
136
    };
137
  }
138
139
  async createTransaction(value: OAuthTransaction): Promise<void> {
140
    await this.ensureSchema();
141
    await this.sql`
142
      INSERT INTO openclasp_oauth_transactions(
143
        transaction_id, client_id, redirect_uri, downstream_state, scope,
144
        code_challenge, auth0_verifier, expires_at
145
      ) VALUES (
146
        ${value.transactionId}, ${value.clientId}, ${value.redirectUri},
147
        ${value.downstreamState ?? null}, ${value.scope}, ${value.codeChallenge},
148
        ${value.auth0Verifier}, NOW() + INTERVAL '10 minutes'
149
      )
150
    `;
151
  }
152
153
  async takeTransaction(transactionId: string): Promise<OAuthTransaction | undefined> {
154
    await this.ensureSchema();
155
    const rows = await this.sql`
156
      DELETE FROM openclasp_oauth_transactions
157
      WHERE transaction_id = ${transactionId} AND expires_at > NOW()
158
      RETURNING transaction_id, client_id, redirect_uri, downstream_state, scope,
159
        code_challenge, auth0_verifier
160
    `;
161
    const row = rows[0];
162
    if (!row) return undefined;
163
    return {
164
      transactionId: String(row.transaction_id),
165
      clientId: String(row.client_id),
166
      redirectUri: String(row.redirect_uri),
167
      ...(typeof row.downstream_state === 'string'
168
        ? { downstreamState: row.downstream_state }
169
        : {}),
170
      scope: String(row.scope),
171
      codeChallenge: String(row.code_challenge),
172
      auth0Verifier: String(row.auth0_verifier),
173
    };
174
  }
175
176
  async createAuthorizationCode(value: OAuthAuthorizationCode): Promise<string> {
177
    await this.ensureSchema();
178
    const code = randomOAuthValue('oc_code_');
179
    await this.sql`
180
      INSERT INTO openclasp_oauth_codes(
181
        code_hash, client_id, operator_id, redirect_uri, scope, code_challenge, expires_at
182
      ) VALUES (
183
        ${digest(code)}, ${value.clientId}, ${value.operatorId}, ${value.redirectUri},
184
        ${value.scope}, ${value.codeChallenge}, NOW() + INTERVAL '5 minutes'
185
      )
186
    `;
187
    return code;
188
  }
189
190
  async takeAuthorizationCode(code: string): Promise<OAuthAuthorizationCode | undefined> {
191
    await this.ensureSchema();
192
    const rows = await this.sql`
193
      UPDATE openclasp_oauth_codes SET used_at = NOW()
194
      WHERE code_hash = ${digest(code)} AND expires_at > NOW() AND used_at IS NULL
195
      RETURNING client_id, operator_id, redirect_uri, scope, code_challenge
196
    `;
197
    const row = rows[0];
198
    if (!row) return undefined;
199
    return {
200
      clientId: String(row.client_id),
201
      operatorId: String(row.operator_id),
202
      redirectUri: String(row.redirect_uri),
203
      scope: String(row.scope),
204
      codeChallenge: String(row.code_challenge),
205
    };
206
  }
207
208
  async issueTokens(
209
    clientId: string,
210
    operatorId: string,
211
    scopes: string[],
212
    includeRefresh = true,
213
  ): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> {
214
    await this.ensureSchema();
215
    const accessToken = randomOAuthValue('oc_oat_');
216
    const expiresIn = 3600;
217
    await this.sql`
218
      INSERT INTO openclasp_oauth_access_tokens(
219
        token_hash, client_id, operator_id, scopes, expires_at
220
      ) VALUES (
221
        ${digest(accessToken)}, ${clientId}, ${operatorId}, ${JSON.stringify(scopes)}::jsonb,
222
        NOW() + INTERVAL '1 hour'
223
      )
224
    `;
225
    if (!includeRefresh) return { accessToken, expiresIn };
226
    const refreshToken = randomOAuthValue('oc_rt_');
227
    await this.sql`
228
      INSERT INTO openclasp_oauth_refresh_tokens(
229
        token_hash, client_id, operator_id, scopes, expires_at
230
      ) VALUES (
231
        ${digest(refreshToken)}, ${clientId}, ${operatorId}, ${JSON.stringify(scopes)}::jsonb,
232
        NOW() + INTERVAL '30 days'
233
      )
234
    `;
235
    return { accessToken, refreshToken, expiresIn };
236
  }
237
238
  async verifyAccessToken(token: string): Promise<OAuthAccessToken | undefined> {
239
    await this.ensureSchema();
240
    const rows = await this.sql`
241
      SELECT client_id, operator_id, scopes, expires_at
242
      FROM openclasp_oauth_access_tokens
243
      WHERE token_hash = ${digest(token)} AND expires_at > NOW() LIMIT 1
244
    `;
245
    const row = rows[0];
246
    if (!row) return undefined;
247
    return {
248
      clientId: String(row.client_id),
249
      operatorId: String(row.operator_id),
250
      scopes: row.scopes as string[],
251
      expiresAt: new Date(String(row.expires_at)).toISOString(),
252
    };
253
  }
254
255
  async rotateRefreshToken(
256
    token: string,
257
    clientId: string,
258
  ): Promise<
259
    | {
260
        clientId: string;
261
        operatorId: string;
262
        scopes: string[];
263
        accessToken: string;
264
        refreshToken: string;
265
        expiresIn: number;
266
      }
267
    | undefined
268
  > {
269
    await this.ensureSchema();
270
    const rows = await this.sql`
271
      UPDATE openclasp_oauth_refresh_tokens SET revoked_at = NOW()
272
      WHERE token_hash = ${digest(token)} AND client_id = ${clientId}
273
        AND expires_at > NOW() AND revoked_at IS NULL
274
      RETURNING client_id, operator_id, scopes
275
    `;
276
    const row = rows[0];
277
    if (!row) return undefined;
278
    const scopes = row.scopes as string[];
279
    const issued = await this.issueTokens(clientId, String(row.operator_id), scopes);
280
    return {
281
      clientId,
282
      operatorId: String(row.operator_id),
283
      scopes,
284
      accessToken: issued.accessToken,
285
      refreshToken: issued.refreshToken!,
286
      expiresIn: issued.expiresIn,
287
    };
288
  }
289
}
290
291
export function oauthStore(): OAuthStore {
292
  const databaseUrl = process.env.DATABASE_URL;
293
  if (!databaseUrl) throw new Error('OAuth persistence is not configured');
294
  return new OAuthStore(databaseUrl);
295
}