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

Viewing files at commit 89b58109a0e6Commit detailsView latest

api/oauth-token.ts 105 lines · 3.7 KB · typescript
1
import { createHash, timingSafeEqual } from 'node:crypto';
2
import { oauthStore, type OAuthStore } from '../apps/api/src/oauth-store.js';
3
import { guardRequest } from '../apps/api/src/request-security.js';
4
import { assertProductionConfiguration } from '../apps/api/src/production-config.js';
5
6
assertProductionConfiguration('auth');
7
8
const noStoreHeaders = {
9
  'access-control-allow-origin': '*',
10
  'cache-control': 'no-store',
11
  pragma: 'no-cache',
12
};
13
14
function tokenError(error: string, description: string, status = 400): Response {
15
  return Response.json(
16
    { error, error_description: description },
17
    { status, headers: noStoreHeaders },
18
  );
19
}
20
21
function matchesChallenge(verifier: string, expected: string): boolean {
22
  const actual = createHash('sha256').update(verifier).digest('base64url');
23
  const left = Buffer.from(actual);
24
  const right = Buffer.from(expected);
25
  return left.length === right.length && timingSafeEqual(left, right);
26
}
27
28
export async function exchange(request: Request, store: OAuthStore): Promise<Response> {
29
  const form = new URLSearchParams(await request.text());
30
  const grantType = form.get('grant_type');
31
  const clientId = form.get('client_id');
32
  if (!clientId) return tokenError('invalid_client', 'client_id is required', 401);
33
34
  if (grantType === 'authorization_code') {
35
    const code = form.get('code');
36
    const redirectUri = form.get('redirect_uri');
37
    const verifier = form.get('code_verifier');
38
    if (!code || !redirectUri || !verifier)
39
      return tokenError('invalid_request', 'Code, redirect URI, and PKCE verifier are required');
40
    const authorization = await store.takeAuthorizationCode(code);
41
    if (
42
      !authorization ||
43
      authorization.clientId !== clientId ||
44
      authorization.redirectUri !== redirectUri ||
45
      !matchesChallenge(verifier, authorization.codeChallenge)
46
    )
47
      return tokenError('invalid_grant', 'Authorization code is invalid or expired');
48
    const scopes = authorization.scope.split(/\s+/).filter(Boolean);
49
    const issued = await store.issueTokens(clientId, authorization.operatorId, scopes);
50
    return Response.json(
51
      {
52
        access_token: issued.accessToken,
53
        token_type: 'Bearer',
54
        expires_in: issued.expiresIn,
55
        refresh_token: issued.refreshToken,
56
        scope: scopes.join(' '),
57
      },
58
      { headers: noStoreHeaders },
59
    );
60
  }
61
62
  if (grantType === 'refresh_token') {
63
    const refreshToken = form.get('refresh_token');
64
    if (!refreshToken) return tokenError('invalid_request', 'refresh_token is required');
65
    const issued = await store.rotateRefreshToken(refreshToken, clientId);
66
    if (!issued) return tokenError('invalid_grant', 'Refresh token is invalid or expired');
67
    return Response.json(
68
      {
69
        access_token: issued.accessToken,
70
        token_type: 'Bearer',
71
        expires_in: issued.expiresIn,
72
        refresh_token: issued.refreshToken,
73
        scope: issued.scopes.join(' '),
74
      },
75
      { headers: noStoreHeaders },
76
    );
77
  }
78
79
  return tokenError('unsupported_grant_type', 'Unsupported grant type');
80
}
81
82
export async function POST(request: Request): Promise<Response> {
83
  const rejected = await guardRequest(request, 'oauth-token', {
84
    limit: 60,
85
    maximumBytes: 16_384,
86
  });
87
  if (rejected) return rejected;
88
  try {
89
    return await exchange(request, oauthStore());
90
  } catch {
91
    return tokenError('temporarily_unavailable', 'OAuth service unavailable', 503);
92
  }
93
}
94
95
export function OPTIONS(): Response {
96
  return new Response(null, {
97
    status: 204,
98
    headers: {
99
      'access-control-allow-headers': 'content-type',
100
      'access-control-allow-methods': 'POST, OPTIONS',
101
      'access-control-allow-origin': '*',
102
      'access-control-max-age': '86400',
103
    },
104
  });
105
}