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

Viewing files at commit 28cda791c50eCommit detailsView latest

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