Skip to content

Commit 956f665

Browse files
committed
Implement token exchange
1 parent 8a26e2d commit 956f665

File tree

1 file changed

+75
-5
lines changed

1 file changed

+75
-5
lines changed

src/client/auth/auth.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@ export const OAuthMetadataSchema = z
3232
})
3333
.passthrough();
3434

35+
export const OAuthTokensSchema = z
36+
.object({
37+
access_token: z.string(),
38+
token_type: z.string(),
39+
expires_in: z.number().optional(),
40+
scope: z.string().optional(),
41+
refresh_token: z.string().optional(),
42+
})
43+
.strip();
44+
3545
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
46+
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
3647

3748
/**
3849
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
@@ -58,18 +69,16 @@ export async function discoverOAuthMetadata(
5869
return OAuthMetadataSchema.parse(await response.json());
5970
}
6071

72+
/**
73+
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
74+
*/
6175
export async function startAuthorization(
6276
serverUrl: string | URL,
6377
{
6478
metadata,
6579
redirectUrl,
6680
}: { metadata: OAuthMetadata; redirectUrl: string | URL },
6781
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
68-
// Generate PKCE challenge
69-
const challenge = await pkceChallenge();
70-
const codeVerifier = challenge.code_verifier;
71-
const codeChallenge = challenge.code_challenge;
72-
7382
const responseType = "code";
7483
const codeChallengeMethod = "S256";
7584

@@ -95,6 +104,11 @@ export async function startAuthorization(
95104
authorizationUrl = new URL("/authorize", serverUrl);
96105
}
97106

107+
// Generate PKCE challenge
108+
const challenge = await pkceChallenge();
109+
const codeVerifier = challenge.code_verifier;
110+
const codeChallenge = challenge.code_challenge;
111+
98112
authorizationUrl.searchParams.set("response_type", responseType);
99113
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
100114
authorizationUrl.searchParams.set(
@@ -105,3 +119,59 @@ export async function startAuthorization(
105119

106120
return { authorizationUrl, codeVerifier };
107121
}
122+
123+
/**
124+
* Exchanges an authorization code for an access token with the given server.
125+
*/
126+
export async function exchangeAuthorization(
127+
serverUrl: string | URL,
128+
{
129+
metadata,
130+
authorizationCode,
131+
codeVerifier,
132+
redirectUrl,
133+
}: {
134+
metadata: OAuthMetadata;
135+
authorizationCode: string;
136+
codeVerifier: string;
137+
redirectUrl: string | URL;
138+
},
139+
): Promise<OAuthTokens> {
140+
const grantType = "authorization_code";
141+
142+
let tokenUrl: URL;
143+
if (metadata) {
144+
tokenUrl = new URL(metadata.token_endpoint);
145+
146+
if (
147+
metadata.grant_types_supported &&
148+
!(grantType in metadata.grant_types_supported)
149+
) {
150+
throw new Error(
151+
`Incompatible auth server: does not support grant type ${grantType}`,
152+
);
153+
}
154+
} else {
155+
tokenUrl = new URL("/token", serverUrl);
156+
}
157+
158+
// Exchange code for tokens
159+
const response = await fetch(tokenUrl, {
160+
method: "POST",
161+
headers: {
162+
"Content-Type": "application/x-www-form-urlencoded",
163+
},
164+
body: new URLSearchParams({
165+
grant_type: grantType,
166+
code: authorizationCode,
167+
code_verifier: codeVerifier,
168+
redirect_uri: String(redirectUrl),
169+
}),
170+
});
171+
172+
if (!response.ok) {
173+
throw new Error(`Token exchange failed: HTTP ${response.status}`);
174+
}
175+
176+
return OAuthTokensSchema.parse(await response.json());
177+
}

0 commit comments

Comments
 (0)