Skip to content

Commit 07cee2b

Browse files
authored
feat(provider): add POST callback and id_token handling for OAuth2 (#245)
* feat(provider): add POST callback and id_token handling for OAuth2 - Add form_post response_mode support for Apple Sign In - Implement POST callback route in OAuth2 provider - Add ID token verification using JWKS endpoint - Refactor callback logic to reduce duplication - Extract and expose decoded ID token claims This change enables Apple Sign In with name and email scopes which requires form_post response mode and proper handling of the ID token. * rm comment * feat(provider): add JWKS endpoint for Google OAuth - Include JWKS endpoint for Google provider to support ID token verification.
1 parent 9795646 commit 07cee2b

File tree

3 files changed

+151
-41
lines changed

3 files changed

+151
-41
lines changed

packages/openauth/src/provider/apple.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@
1616
* })
1717
* ```
1818
*
19+
* #### Using OAuth with form_post response mode
20+
*
21+
* When requesting name or email scopes from Apple, you must use form_post response mode:
22+
*
23+
* ```ts {5-9}
24+
* import { AppleProvider } from "@openauthjs/openauth/provider/apple"
25+
*
26+
* export default issuer({
27+
* providers: {
28+
* apple: AppleProvider({
29+
* clientID: "1234567890",
30+
* clientSecret: "0987654321",
31+
* responseMode: "form_post"
32+
* })
33+
* }
34+
* })
35+
* ```
36+
*
1937
* #### Using OIDC
2038
*
2139
* ```ts {5-7}
@@ -36,7 +54,14 @@
3654
import { Oauth2Provider, Oauth2WrappedConfig } from "./oauth2.js"
3755
import { OidcProvider, OidcWrappedConfig } from "./oidc.js"
3856

39-
export interface AppleConfig extends Oauth2WrappedConfig {}
57+
export interface AppleConfig extends Oauth2WrappedConfig {
58+
/**
59+
* The response mode to use for the authorization request.
60+
* Apple requires 'form_post' response mode when requesting name or email scopes.
61+
* @default "query"
62+
*/
63+
responseMode?: "query" | "form_post"
64+
}
4065
export interface AppleOidcConfig extends OidcWrappedConfig {}
4166

4267
/**
@@ -45,20 +70,37 @@ export interface AppleOidcConfig extends OidcWrappedConfig {}
4570
* @param config - The config for the provider.
4671
* @example
4772
* ```ts
73+
* // Using default query response mode (GET callback)
4874
* AppleProvider({
4975
* clientID: "1234567890",
5076
* clientSecret: "0987654321"
5177
* })
78+
*
79+
* // Using form_post response mode (POST callback)
80+
* // Required when requesting name or email scope
81+
* AppleProvider({
82+
* clientID: "1234567890",
83+
* clientSecret: "0987654321",
84+
* responseMode: "form_post",
85+
* scopes: ["name", "email"]
86+
* })
5287
* ```
5388
*/
5489
export function AppleProvider(config: AppleConfig) {
90+
const { responseMode, ...restConfig } = config
91+
const additionalQuery = responseMode === "form_post"
92+
? { response_mode: "form_post", ...config.query }
93+
: config.query || {}
94+
5595
return Oauth2Provider({
56-
...config,
96+
...restConfig,
5797
type: "apple" as const,
5898
endpoint: {
5999
authorization: "https://appleid.apple.com/auth/authorize",
60100
token: "https://appleid.apple.com/auth/token",
101+
jwks: "https://appleid.apple.com/auth/keys",
61102
},
103+
query: additionalQuery,
62104
})
63105
}
64106

@@ -80,5 +122,6 @@ export function AppleOidcProvider(config: AppleOidcConfig) {
80122
...config,
81123
type: "apple" as const,
82124
issuer: "https://appleid.apple.com",
125+
83126
})
84127
}

packages/openauth/src/provider/google.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function GoogleProvider(config: GoogleConfig) {
5858
endpoint: {
5959
authorization: "https://accounts.google.com/o/oauth2/v2/auth",
6060
token: "https://oauth2.googleapis.com/token",
61+
jwks: "https://www.googleapis.com/oauth2/v3/certs",
6162
},
6263
})
6364
}

packages/openauth/src/provider/oauth2.ts

Lines changed: 105 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* @packageDocumentation
2323
*/
2424

25+
import { createRemoteJWKSet, jwtVerify } from "jose"
2526
import { OauthError } from "../error.js"
2627
import { generatePKCE } from "../pkce.js"
2728
import { getRelativeUrl } from "../util.js"
@@ -66,7 +67,8 @@ export interface Oauth2Config {
6667
* {
6768
* endpoint: {
6869
* authorization: "https://auth.myserver.com/authorize",
69-
* token: "https://auth.myserver.com/token"
70+
* token: "https://auth.myserver.com/token",
71+
* jwks: "https://auth.myserver.com/auth/keys"
7072
* }
7173
* }
7274
* ```
@@ -80,6 +82,10 @@ export interface Oauth2Config {
8082
* The URL of the token endpoint.
8183
*/
8284
token: string
85+
/**
86+
* The URL of the JWKS endpoint.
87+
*/
88+
jwks?: string
8389
}
8490
/**
8591
* A list of OAuth scopes that you want to request.
@@ -125,6 +131,7 @@ export interface Oauth2Token {
125131
access: string
126132
refresh: string
127133
expiry: number
134+
id?: Record<string, any>
128135
raw: Record<string, any>
129136
}
130137

@@ -138,6 +145,76 @@ export function Oauth2Provider(
138145
config: Oauth2Config,
139146
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
140147
const query = config.query || {}
148+
149+
// Helper function to handle token exchange and response building
150+
async function handleCallbackLogic(
151+
c: any,
152+
ctx: any,
153+
provider: ProviderState,
154+
code: string | undefined
155+
) {
156+
if (!provider || !code) {
157+
return c.redirect(getRelativeUrl(c, "./authorize"));
158+
}
159+
160+
const body = new URLSearchParams({
161+
client_id: config.clientID,
162+
client_secret: config.clientSecret,
163+
code,
164+
grant_type: "authorization_code",
165+
redirect_uri: provider.redirect,
166+
...(provider.codeVerifier
167+
? { code_verifier: provider.codeVerifier }
168+
: {}),
169+
});
170+
171+
const json: any = await fetch(config.endpoint.token, {
172+
method: "POST",
173+
headers: {
174+
"Content-Type": "application/x-www-form-urlencoded",
175+
Accept: "application/json",
176+
},
177+
body: body.toString(),
178+
}).then((r) => r.json());
179+
180+
if ("error" in json) {
181+
throw new OauthError(json.error, json.error_description);
182+
}
183+
184+
let idTokenPayload: Record<string, any> | null = null;
185+
if (config.endpoint.jwks) {
186+
const jwksEndpoint = new URL(config.endpoint.jwks);
187+
// @ts-expect-error bun/node mismatch
188+
const jwks = createRemoteJWKSet(jwksEndpoint);
189+
const { payload } = await jwtVerify(json.id_token, jwks, {
190+
audience: config.clientID,
191+
});
192+
idTokenPayload = payload;
193+
}
194+
195+
return ctx.success(c, {
196+
clientID: config.clientID,
197+
tokenset: {
198+
get access() {
199+
return json.access_token;
200+
},
201+
get refresh() {
202+
return json.refresh_token;
203+
},
204+
get expiry() {
205+
return json.expires_in;
206+
},
207+
get id() {
208+
if (!idTokenPayload) return null;
209+
return idTokenPayload;
210+
},
211+
get raw() {
212+
return json;
213+
},
214+
},
215+
});
216+
}
217+
141218
return {
142219
type: config.type || "oauth2",
143220
init(routes, ctx) {
@@ -173,50 +250,39 @@ export function Oauth2Provider(
173250
const code = c.req.query("code")
174251
const state = c.req.query("state")
175252
const error = c.req.query("error")
253+
176254
if (error)
177255
throw new OauthError(
178256
error.toString() as any,
179257
c.req.query("error_description")?.toString() || "",
180258
)
181-
if (!provider || !code || (provider.state && state !== provider.state))
259+
if (!provider || !code || (provider.state && state !== provider.state)) {
182260
return c.redirect(getRelativeUrl(c, "./authorize"))
183-
const body = new URLSearchParams({
184-
client_id: config.clientID,
185-
client_secret: config.clientSecret,
186-
code,
187-
grant_type: "authorization_code",
188-
redirect_uri: provider.redirect,
189-
...(provider.codeVerifier
190-
? { code_verifier: provider.codeVerifier }
191-
: {}),
192-
})
193-
const json: any = await fetch(config.endpoint.token, {
194-
method: "POST",
195-
headers: {
196-
"Content-Type": "application/x-www-form-urlencoded",
197-
Accept: "application/json",
198-
},
199-
body: body.toString(),
200-
}).then((r) => r.json())
201-
if ("error" in json)
202-
throw new OauthError(json.error, json.error_description)
203-
return ctx.success(c, {
204-
clientID: config.clientID,
205-
tokenset: {
206-
get access() {
207-
return json.access_token
208-
},
209-
get refresh() {
210-
return json.refresh_token
211-
},
212-
get expiry() {
213-
return json.expires_in
214-
},
215-
get raw() {
216-
return json
217-
},
218-
},
219-
})
261+
}
262+
263+
return handleCallbackLogic(c, ctx, provider, code)
264+
})
265+
266+
routes.post("/callback", async (c) => {
267+
const provider = (await ctx.get(c, "provider")) as ProviderState
268+
269+
// Handle form data from POST request
270+
const formData = await c.req.formData()
271+
const code = formData.get("code")?.toString()
272+
const state = formData.get("state")?.toString()
273+
const error = formData.get("error")?.toString()
274+
275+
if (error)
276+
throw new OauthError(
277+
error as any,
278+
formData.get("error_description")?.toString() || "",
279+
)
280+
281+
if (!provider || !code || (provider.state && state !== provider.state)) {
282+
return c.redirect(getRelativeUrl(c, "./authorize"))
283+
}
284+
285+
return handleCallbackLogic(c, ctx, provider, code)
220286
})
221287
},
222288
}

0 commit comments

Comments
 (0)