Skip to content

Commit d927e4e

Browse files
committed
refactor(core): enhance error handling
1 parent 79db932 commit d927e4e

File tree

18 files changed

+309
-278
lines changed

18 files changed

+309
-278
lines changed

packages/core/src/@types/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,23 @@ export type AccessTokenError = OAuthError<z.infer<typeof OAuthAccessTokenErrorRe
245245
* OAuth 2.0 Token Revocation Error Response Types
246246
* @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
247247
*/
248-
export type TokenRevocationError = OAuthError<"invalid_session_token" | "invalid_csrf_token" | "invalid_redirect_to">
248+
export type TokenRevocationError = OAuthError<"invalid_session_token">
249249

250250
export type ErrorType = AuthorizationError["error"] | AccessTokenError["error"] | TokenRevocationError["error"]
251+
252+
export type AuthInternalErrorCode =
253+
| "INVALID_OAUTH_CONFIGURATION"
254+
| "INVALID_JWT_TOKEN"
255+
| "JOSE_INITIALIZATION_FAILED"
256+
| "SESSION_STORE_NOT_INITIALIZED"
257+
| "COOKIE_STORE_NOT_INITIALIZED"
258+
| "COOKIE_PARSING_FAILED"
259+
| "COOKIE_NOT_FOUND"
260+
261+
export type AuthSecurityErrorCode =
262+
| "INVALID_STATE"
263+
| "MISMATCHING_STATE"
264+
| "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED"
265+
| "CSRF_TOKEN_INVALID"
266+
| "CSRF_TOKEN_MISSING"
267+
| "SESSION_TOKEN_MISSING"

packages/core/src/actions/callback/access-token.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { AuthError, ERROR_RESPONSE, throwAuthError } from "@/errors.js"
1+
import { AuthInternalError, OAuthProtocolError } from "@/errors.js"
22
import { OAuthAccessToken, OAuthAccessTokenErrorResponse, OAuthAccessTokenResponse } from "@/schemas.js"
33
import type { OAuthProviderCredentials } from "@/@types/index.js"
4+
import { formatZodError } from "@/utils.js"
45

56
/**
67
* Make a request to the OAuth provider to the token endpoint to exchange the authorization code provided
@@ -21,7 +22,8 @@ export const createAccessToken = async (
2122
) => {
2223
const parsed = OAuthAccessToken.safeParse({ ...oauthConfig, redirectURI, code, codeVerifier })
2324
if (!parsed.success) {
24-
throw new AuthError(ERROR_RESPONSE.ACCESS_TOKEN.INVALID_REQUEST, "Invalid OAuth configuration")
25+
const msg = formatZodError(parsed.error).toString()
26+
throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", msg)
2527
}
2628
const { accessToken, clientId, clientSecret, code: codeParsed, redirectURI: redirectParsed } = parsed.data
2729
try {
@@ -45,12 +47,16 @@ export const createAccessToken = async (
4547
if (!token.success) {
4648
const { success, data } = OAuthAccessTokenErrorResponse.safeParse(json)
4749
if (!success) {
48-
throw new AuthError(ERROR_RESPONSE.ACCESS_TOKEN.INVALID_GRANT, "Invalid access token response format")
50+
throw new OAuthProtocolError("INVALID_REQUEST", "Invalid access token response format")
4951
}
50-
throw new AuthError(data.error, data?.error_description ?? "Failed to retrieve access token")
52+
throw new OAuthProtocolError(data.error, data?.error_description ?? "Failed to retrieve access token")
5153
}
5254
return token.data
5355
} catch (error) {
54-
throw throwAuthError(error, "Failed to create access token")
56+
/**
57+
* @todo: review error handling here
58+
*/
59+
//throw throwAuthError(error, "Failed to create access token")
60+
throw error
5561
}
5662
}

packages/core/src/actions/callback/callback.ts

Lines changed: 37 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import z from "zod"
2-
import { createEndpoint, createEndpointConfig, HeadersBuilder, statusCode } from "@aura-stack/router"
2+
import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router"
33
import { createCSRF } from "@/secure.js"
44
import { cacheControl } from "@/headers.js"
5-
import { AuraResponse } from "@/response.js"
65
import { getUserInfo } from "@/actions/callback/userinfo.js"
7-
import { AuthError, ERROR_RESPONSE, isAuthError } from "@/errors.js"
86
import { equals, isValidRelativePath, sanitizeURL } from "@/utils.js"
97
import { createAccessToken } from "@/actions/callback/access-token.js"
10-
import { OAuthAuthorizationErrorResponse, OAuthAuthorizationResponse } from "@/schemas.js"
118
import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.js"
9+
import { OAuthAuthorizationErrorResponse, OAuthAuthorizationResponse } from "@/schemas.js"
10+
import { AuthSecurityError, OAuthProtocolError } from "@/errors.js"
1211
import type { JWTPayload } from "@/jose.js"
13-
import type { AccessTokenError, AuthorizationError, AuthRuntimeConfig } from "@/@types/index.js"
12+
import type { AuthRuntimeConfig } from "@/@types/index.js"
1413

1514
const callbackConfig = (oauth: AuthRuntimeConfig["oauth"]) => {
1615
return createEndpointConfig("/callback/:oauth", {
@@ -25,7 +24,7 @@ const callbackConfig = (oauth: AuthRuntimeConfig["oauth"]) => {
2524
const response = OAuthAuthorizationErrorResponse.safeParse(ctx.searchParams)
2625
if (response.success) {
2726
const { error, error_description } = response.data
28-
throw new AuthError(error, error_description ?? "OAuth Authorization Error")
27+
throw new OAuthProtocolError(error, error_description ?? "OAuth Authorization Error")
2928
}
3029
return ctx
3130
},
@@ -44,58 +43,43 @@ export const callbackAction = (oauth: AuthRuntimeConfig["oauth"]) => {
4443
searchParams: { code, state },
4544
context: { oauth: providers, cookies, jose },
4645
} = ctx
47-
try {
48-
const oauthConfig = providers[oauth]
49-
const cookieState = getCookie(request, cookies.state.name)
50-
const cookieRedirectTo = getCookie(request, cookies.redirect_to.name)
51-
const cookieRedirectURI = getCookie(request, cookies.redirect_uri.name)
52-
const codeVerifier = getCookie(request, cookies.code_verifier.name)
53-
54-
if (!equals(cookieState, state)) {
55-
throw new AuthError(ERROR_RESPONSE.ACCESS_TOKEN.INVALID_REQUEST, "Mismatching state")
56-
}
57-
58-
const accessToken = await createAccessToken(oauthConfig, cookieRedirectURI, code, codeVerifier)
59-
const sanitized = sanitizeURL(cookieRedirectTo)
60-
if (!isValidRelativePath(sanitized)) {
61-
throw new AuthError(
62-
ERROR_RESPONSE.ACCESS_TOKEN.INVALID_REQUEST,
63-
"Invalid redirect path. Potential open redirect attack detected."
64-
)
65-
}
6646

67-
const userInfo = await getUserInfo(oauthConfig, accessToken.access_token)
47+
const oauthConfig = providers[oauth]
48+
const cookieState = getCookie(request, cookies.state.name)
49+
const cookieRedirectTo = getCookie(request, cookies.redirect_to.name)
50+
const cookieRedirectURI = getCookie(request, cookies.redirect_uri.name)
51+
const codeVerifier = getCookie(request, cookies.code_verifier.name)
6852

69-
const sessionCookie = await createSessionCookie(userInfo as JWTPayload, jose)
70-
71-
const csrfToken = await createCSRF(jose)
53+
if (!equals(cookieState, state)) {
54+
throw new AuthSecurityError(
55+
"MISMATCHING_STATE",
56+
"The provided state passed in the OAuth response does not match the stored state."
57+
)
58+
}
7259

73-
const headers = new HeadersBuilder(cacheControl)
74-
.setHeader("Location", sanitized)
75-
.setCookie(cookies.sessionToken.name, sessionCookie, cookies.sessionToken.attributes)
76-
.setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes)
77-
.setCookie(cookies.state.name, "", expiredCookieAttributes)
78-
.setCookie(cookies.redirect_uri.name, "", expiredCookieAttributes)
79-
.setCookie(cookies.redirect_to.name, "", expiredCookieAttributes)
80-
.setCookie(cookies.code_verifier.name, "", expiredCookieAttributes)
81-
.toHeaders()
82-
return Response.json({ oauth }, { status: 302, headers: headers })
83-
} catch (error) {
84-
if (isAuthError(error)) {
85-
const { type, message } = error
86-
return AuraResponse.json<AuthorizationError>(
87-
{ error: type as AuthorizationError["error"], error_description: message },
88-
{ status: statusCode.BAD_REQUEST }
89-
)
90-
}
91-
return AuraResponse.json<AccessTokenError>(
92-
{
93-
error: ERROR_RESPONSE.ACCESS_TOKEN.INVALID_CLIENT as AccessTokenError["error"],
94-
error_description: "An unexpected error occurred",
95-
},
96-
{ status: statusCode.INTERNAL_SERVER_ERROR }
60+
const accessToken = await createAccessToken(oauthConfig, cookieRedirectURI, code, codeVerifier)
61+
const sanitized = sanitizeURL(cookieRedirectTo)
62+
if (!isValidRelativePath(sanitized)) {
63+
throw new AuthSecurityError(
64+
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
65+
"Invalid redirect path. Potential open redirect attack detected."
9766
)
9867
}
68+
69+
const userInfo = await getUserInfo(oauthConfig, accessToken.access_token)
70+
const sessionCookie = await createSessionCookie(userInfo as JWTPayload, jose)
71+
const csrfToken = await createCSRF(jose)
72+
73+
const headers = new HeadersBuilder(cacheControl)
74+
.setHeader("Location", sanitized)
75+
.setCookie(cookies.sessionToken.name, sessionCookie, cookies.sessionToken.attributes)
76+
.setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes)
77+
.setCookie(cookies.state.name, "", expiredCookieAttributes)
78+
.setCookie(cookies.redirect_uri.name, "", expiredCookieAttributes)
79+
.setCookie(cookies.redirect_to.name, "", expiredCookieAttributes)
80+
.setCookie(cookies.code_verifier.name, "", expiredCookieAttributes)
81+
.toHeaders()
82+
return Response.json({ oauth }, { status: 302, headers: headers })
9983
},
10084
callbackConfig(oauth)
10185
)

packages/core/src/actions/callback/userinfo.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { generateSecure } from "@/secure.js"
22
import { OAuthErrorResponse } from "@/schemas.js"
3-
import { AuthError, throwAuthError } from "@/errors.js"
3+
import { isNativeError, isOAuthProtocolError, OAuthProtocolError } from "@/errors.js"
44
import type { OAuthProviderCredentials, User } from "@/@types/index.js"
55

66
/**
@@ -42,10 +42,19 @@ export const getUserInfo = async (oauthConfig: OAuthProviderCredentials, accessT
4242
const json = await response.json()
4343
const { success, data } = OAuthErrorResponse.safeParse(json)
4444
if (success) {
45-
throw new AuthError(data.error, data?.error_description ?? "An error occurred while fetching user information.")
45+
throw new OAuthProtocolError(
46+
data.error,
47+
data?.error_description ?? "An error occurred while fetching user information."
48+
)
4649
}
4750
return oauthConfig?.profile ? oauthConfig.profile(json) : getDefaultUserInfo(json)
4851
} catch (error) {
49-
throw throwAuthError(error, "Failed to retrieve userinfo")
52+
if(isOAuthProtocolError(error)) {
53+
throw error
54+
}
55+
if(isNativeError(error)) {
56+
throw new OAuthProtocolError("invalid_request", error.message, "", { cause: error })
57+
}
58+
throw new OAuthProtocolError("invalid_request", "Failed to fetch user information.", "", { cause: error })
5059
}
5160
}

packages/core/src/actions/signIn/authorization.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isValidURL } from "@/assert.js"
22
import { OAuthAuthorization } from "@/schemas.js"
3-
import { equals, getNormalizedOriginPath, sanitizeURL, toCastCase } from "@/utils.js"
4-
import { AuthError, ERROR_RESPONSE, InvalidRedirectToError, isAuthError } from "@/errors.js"
3+
import { equals, formatZodError, getNormalizedOriginPath, sanitizeURL, toCastCase } from "@/utils.js"
4+
import { AuthInternalError, AuthSecurityError, isAuthSecurityError } from "@/errors.js"
55
import type { OAuthProviderCredentials } from "@/@types/index.js"
66

77
/**
@@ -25,7 +25,9 @@ export const createAuthorizationURL = (
2525
) => {
2626
const parsed = OAuthAuthorization.safeParse({ ...oauthConfig, redirectURI, state, codeChallenge, codeChallengeMethod })
2727
if (!parsed.success) {
28-
throw new AuthError(ERROR_RESPONSE.AUTHORIZATION.SERVER_ERROR, "Invalid OAuth configuration")
28+
const msg = JSON.stringify(formatZodError(parsed.error), null, 2)
29+
console.log("OAuth Authorization URL Creation Error:", msg)
30+
throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", msg)
2931
}
3032
const { authorizeURL, ...options } = parsed.data
3133
const { userInfo, accessToken, clientSecret, ...required } = options
@@ -82,15 +84,18 @@ export const createRedirectTo = (request: Request, redirectTo?: string, trustedP
8284
}
8385
const redirectToURL = new URL(sanitizeURL(getNormalizedOriginPath(redirectTo)))
8486
if (!isValidURL(redirectTo) || !equals(redirectToURL.origin, hostedURL.origin)) {
85-
throw new InvalidRedirectToError()
87+
throw new AuthSecurityError(
88+
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
89+
"The redirectTo parameter does not match the hosted origin."
90+
)
8691
}
8792
return sanitizeURL(redirectToURL.pathname)
8893
}
8994
if (referer) {
9095
const refererURL = new URL(sanitizeURL(referer))
9196
if (!isValidURL(referer) || !equals(refererURL.origin, hostedURL.origin)) {
92-
throw new AuthError(
93-
ERROR_RESPONSE.AUTHORIZATION.INVALID_REQUEST,
97+
throw new AuthSecurityError(
98+
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
9499
"The referer of the request does not match the hosted origin."
95100
)
96101
}
@@ -99,15 +104,15 @@ export const createRedirectTo = (request: Request, redirectTo?: string, trustedP
99104
if (origin) {
100105
const originURL = new URL(sanitizeURL(getNormalizedOriginPath(origin)))
101106
if (!isValidURL(origin) || !equals(originURL.origin, hostedURL.origin)) {
102-
throw new AuthError(ERROR_RESPONSE.AUTHORIZATION.INVALID_REQUEST, "Invalid origin (potential CSRF).")
107+
throw new AuthSecurityError("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", "Invalid origin (potential CSRF).")
103108
}
104109
return sanitizeURL(originURL.pathname)
105110
}
106111
return "/"
107112
} catch (error) {
108-
if (isAuthError(error)) {
113+
if (isAuthSecurityError(error)) {
109114
throw error
110115
}
111-
throw new AuthError(ERROR_RESPONSE.AUTHORIZATION.INVALID_REQUEST, "Invalid origin (potential CSRF).")
116+
throw new AuthSecurityError("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", "Invalid origin (potential CSRF).")
112117
}
113118
}

packages/core/src/actions/signIn/signIn.ts

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import z from "zod"
2-
import { createEndpoint, createEndpointConfig, statusCode } from "@aura-stack/router"
3-
import { AuraResponse } from "@/response.js"
2+
import { createEndpoint, createEndpointConfig } from "@aura-stack/router"
43
import { createPKCE, generateSecure } from "@/secure.js"
5-
import { ERROR_RESPONSE, isAuthError } from "@/errors.js"
64
import { createAuthorizationURL, createRedirectURI, createRedirectTo } from "@/actions/signIn/authorization.js"
7-
import type { AuthorizationError, AuthRuntimeConfig } from "@/@types/index.js"
5+
import type { AuthRuntimeConfig } from "@/@types/index.js"
86

97
const signInConfig = (oauth: AuthRuntimeConfig["oauth"]) => {
108
return createEndpointConfig("/signIn/:oauth", {
@@ -33,44 +31,27 @@ export const signInAction = (oauth: AuthRuntimeConfig["oauth"]) => {
3331
params: { oauth, redirectTo },
3432
context: { oauth: providers, cookies, trustedProxyHeaders, basePath },
3533
} = ctx
36-
try {
37-
const state = generateSecure()
38-
const redirectURI = createRedirectURI(request, oauth, basePath, trustedProxyHeaders)
39-
const redirectToValue = createRedirectTo(request, redirectTo, trustedProxyHeaders)
34+
const state = generateSecure()
35+
const redirectURI = createRedirectURI(request, oauth, basePath, trustedProxyHeaders)
36+
const redirectToValue = createRedirectTo(request, redirectTo, trustedProxyHeaders)
4037

41-
const { codeVerifier, codeChallenge, method } = await createPKCE()
42-
const authorization = createAuthorizationURL(providers[oauth], redirectURI, state, codeChallenge, method)
38+
const { codeVerifier, codeChallenge, method } = await createPKCE()
39+
const authorization = createAuthorizationURL(providers[oauth], redirectURI, state, codeChallenge, method)
4340

44-
const headers = headersBuilder
45-
.setHeader("Location", authorization)
46-
.setCookie(cookies.state.name, state, cookies.state.attributes)
47-
.setCookie(cookies.redirect_uri.name, redirectURI, cookies.redirect_uri.attributes)
48-
.setCookie(cookies.redirect_to.name, redirectToValue, cookies.redirect_to.attributes)
49-
.setCookie(cookies.code_verifier.name, codeVerifier, cookies.code_verifier.attributes)
50-
.toHeaders()
51-
return Response.json(
52-
{ oauth },
53-
{
54-
status: 302,
55-
headers,
56-
}
57-
)
58-
} catch (error) {
59-
if (isAuthError(error)) {
60-
const { type, message } = error
61-
return AuraResponse.json<AuthorizationError>(
62-
{ error: type as AuthorizationError["error"], error_description: message },
63-
{ status: statusCode.BAD_REQUEST }
64-
)
41+
const headers = headersBuilder
42+
.setHeader("Location", authorization)
43+
.setCookie(cookies.state.name, state, cookies.state.attributes)
44+
.setCookie(cookies.redirect_uri.name, redirectURI, cookies.redirect_uri.attributes)
45+
.setCookie(cookies.redirect_to.name, redirectToValue, cookies.redirect_to.attributes)
46+
.setCookie(cookies.code_verifier.name, codeVerifier, cookies.code_verifier.attributes)
47+
.toHeaders()
48+
return Response.json(
49+
{ oauth },
50+
{
51+
status: 302,
52+
headers,
6553
}
66-
return AuraResponse.json<AuthorizationError>(
67-
{
68-
error: ERROR_RESPONSE.AUTHORIZATION.SERVER_ERROR as AuthorizationError["error"],
69-
error_description: "An unexpected error occurred",
70-
},
71-
{ status: statusCode.INTERNAL_SERVER_ERROR }
72-
)
73-
}
54+
)
7455
},
7556
signInConfig(oauth)
7657
)

0 commit comments

Comments
 (0)