Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export interface SessionStrategy<DefaultUser extends User = User> {
* Read and validate the session from an incoming request.
* Returns null if absent, invalid, or expired. Never throws on auth failure.
*/
getSession(request: Headers): Promise<GetStatelessSessionReturn<DefaultUser>>
getSession(headers: Headers): Promise<GetStatelessSessionReturn<DefaultUser>>

/**
* Create a session after successful authentication.
Expand Down Expand Up @@ -260,7 +260,12 @@ export interface SessionStrategy<DefaultUser extends User = User> {
* Destroy the session attached to this request (logout).
* Returns a response that clears cookies.
*/
destroySession(request: Headers, skipCSRFCheck?: boolean): Promise<Headers>
destroySession(headers: Headers, skipCSRFCheck?: boolean): Promise<Headers>

/**
* Revoke the access token for a specific OAuth provider.
*/
revokeToken(oauth: string, headers: Headers, disconnect: boolean): Promise<Headers>
Comment thread
halvaradop marked this conversation as resolved.
}

/** Inputs for constructing a session strategy implementation for a given identity schema. */
Expand Down
74 changes: 4 additions & 70 deletions packages/core/src/api/revokeToken.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,13 @@
import { AuraAuthError } from "@/shared/errors.ts"
import { HeadersBuilder } from "@aura-stack/router"
import { fetchAsync } from "@/shared/fetch-async.ts"
import { secureApiHeaders } from "@/shared/headers.ts"
import { getCookie, getExpiredCookie } from "@/cookie.ts"
import { createValidation, handleApiError } from "@/shared/utils/api.ts"
import { createBasicAuthHeader, toUnionHeaders } from "@/shared/utils.ts"
import type {
FunctionAPIContext,
RevokeTokenAPIOptions,
RevokeTokenAPIReturn,
LiteralUnion,
BuiltInOAuthProvider,
RuntimeOAuthProvider,
} from "@/@types/index.ts"

const revokeProviderToken = async (provider: RuntimeOAuthProvider, accessToken: string) => {
if (!provider.revokeToken || (typeof provider.revokeToken === "object" && !("url" in provider.revokeToken))) {
throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_CONFIG" })
}
if (!accessToken) {
throw new AuraAuthError({ code: "INVALID_ACCESS_TOKEN" })
}
const { tokenHint: hintParam, ...extraParams } =
typeof provider.revokeToken === "object" && provider.revokeToken.params
? provider.revokeToken.params
: ({} as Record<string, string>)
const tokenHint = hintParam ?? "access_token"

const url = typeof provider.revokeToken === "string" ? provider.revokeToken : provider.revokeToken.url
const basicAuth = createBasicAuthHeader(provider.clientId!, provider.clientSecret!)

const response = await fetchAsync(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuth,
...(typeof provider.revokeToken === "object" && provider.revokeToken.headers ? provider.revokeToken.headers : {}),
},
body: new URLSearchParams({
token: accessToken,
token_type_hint: tokenHint,
...extraParams,
}),
})
if (!response.ok) {
throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_RESPONSE" })
}
if (response.status !== 200 && response.status !== 204) {
throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_PROCESS" })
}
return true
}

export const revokeToken = async (
oauth: LiteralUnion<BuiltInOAuthProvider>,
{
Expand All @@ -63,7 +19,6 @@ export const revokeToken = async (
disconnect = false,
}: FunctionAPIContext<RevokeTokenAPIOptions> & { disconnect?: boolean }
): Promise<RevokeTokenAPIReturn> => {
const { cookies } = ctx
try {
ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", {
structuredData: {
Expand All @@ -73,7 +28,7 @@ export const revokeToken = async (
},
})

const { provider, headers, request, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers)
const { headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers)
.verifyOAuthProvider(oauth)
.verifySession()
.verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken)
Expand All @@ -88,33 +43,12 @@ export const revokeToken = async (
return rateLimit as RevokeTokenAPIReturn
}

const cookieName = `${cookies.accessToken.name}.${oauth}`
const cookie = getCookie(request, cookieName)

const decodedToken = await ctx.jwtManager.verifyToken(cookie)
const tokens = await ctx.identity.schemaRegistry.parseOAuthTokens(decodedToken)

if (!disconnect) {
ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", {
structuredData: { provider: oauth, hasAccessToken: !!tokens.accessToken },
})

await revokeProviderToken(provider!, tokens.accessToken)

ctx.logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", {
structuredData: { provider: oauth },
})
}

const builder = new HeadersBuilder(secureApiHeaders)
.setCookie(cookieName, "", getExpiredCookie(cookies.accessToken.attributes))
.toHeaders()
const newHeaders = toUnionHeaders(builder, headers)
const revokeHeaders = await ctx.sessionStrategy.revokeToken(oauth, headers, disconnect)
return {
success: true,
headers: newHeaders,
headers: revokeHeaders,
toResponse: () => {
return Response.json({ success: true }, { status: 200, headers: newHeaders })
return Response.json({ success: true }, { status: 200, headers: revokeHeaders })
},
}
} catch (error) {
Expand Down
116 changes: 115 additions & 1 deletion packages/core/src/session/stateful.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AuraAuthError } from "@/shared/errors.ts"
import { secureApiHeaders } from "@/shared/headers.ts"
import { verifyCSRFToken, getErrorName, shouldRefresh } from "@/shared/utils.ts"
import { verifyCSRFToken, getErrorName, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts"
import { handleApiError } from "@/shared/utils/api.ts"
import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts"
import { revokeProviderToken } from "@/shared/utils/revoke-token.ts"
import { createCookieManager } from "@/session/cookie-manager.ts"
import { createHash, createSecretValue } from "@/shared/crypto.ts"
import type { JoseInstance } from "@/@types/index.ts"
Expand All @@ -16,6 +17,8 @@ import type {
SessionStrategy,
User,
} from "@/@types/session.ts"
import { HeadersBuilder } from "@aura-stack/router"
import { getExpiredCookie } from "@/cookie.ts"

export const createStatefulStrategy = <DefaultUser extends User = User>({
config,
Expand Down Expand Up @@ -845,11 +848,122 @@ export const createStatefulStrategy = <DefaultUser extends User = User>({
}
}

const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise<Headers> => {
logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", {
structuredData: {
provider: oauthId,
operation: "revokeToken",
disconnect,
},
})

try {
const { sessionToken } = cookieConfig.getCookie(headers)
if (!sessionToken) {
logger?.log("SESSION_TOKEN_MISSING", {
structuredData: {
reason: "no_session_token",
},
})
throw new AuraAuthError({ code: "SESSION_NOT_FOUND" })
}

const sessionByToken = await config.adapter.getSessionByToken(sessionToken)
if (!sessionByToken || !sessionByToken.user) {
logger?.log("AUTH_SESSION_INVALID", {
structuredData: {
reason: "session_not_found_or_no_user",
},
})
throw new AuraAuthError({ code: "SESSION_NOT_FOUND" })
}

const isExpired = Date.now() > sessionByToken.expiresAt.getTime()
if (sessionByToken.status !== "active" || isExpired) {
if (isExpired) {
await config.adapter.revokeSession(sessionByToken.id, "user_logout")
}
logger?.log("AUTH_SESSION_INVALID", {
structuredData: {
reason: "session_expired_or_inactive",
},
})
throw new AuraAuthError({ code: "SESSION_NOT_FOUND" })
}

logger?.log("AUTH_SESSION_VALID", {
structuredData: {
user_id: sessionByToken.userId,
session_id: sessionByToken.id,
},
})

const oauthAccount = await config.adapter.getOAuthAccount(oauthId)
if (!oauthAccount) {
logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", {
structuredData: {
provider: oauthId,
reason: "oauth_account_not_found",
},
})
throw new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" })
}

const provider = oauth?.[oauthId]
if (!provider) {
logger?.log("INVALID_OAUTH_CONFIGURATION", {
structuredData: {
provider: oauthId,
reason: "provider_not_configured",
},
})
throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" })
}

if (!disconnect) {
logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", {
structuredData: { provider: oauthId, hasAccessToken: !!oauthAccount.accessToken },
})

await revokeProviderToken(provider, oauthAccount.accessToken)

logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", {
structuredData: { provider: oauthId },
})
}

await config.adapter.updateAccountStatus(oauthAccount.accountId, "unlinked")

logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", {
structuredData: {
provider: oauthId,
account_unlinked: true,
},
})

const cookieName = `${cookies().accessToken.name}.${oauthId}`
const builder = new HeadersBuilder(secureApiHeaders)
.setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes))
.toHeaders()
return toUnionHeaders(builder, headers)
Comment thread
halvaradop marked this conversation as resolved.
} catch (error) {
logger?.log("OAUTH_ACCESS_TOKEN_ERROR", {
structuredData: {
provider: oauthId,
error_type: getErrorName(error),
error_message: error instanceof Error ? error.message : String(error),
},
})
throw error
}
}

return {
getSession,
createSession,
refreshSession,
revokeSession,
revokeToken,
destroySession,
getProviderTokens,
}
Expand Down
34 changes: 32 additions & 2 deletions packages/core/src/session/stateless.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getCookie } from "@/cookie.ts"
import { getCookie, getExpiredCookie } from "@/cookie.ts"
import { AuraAuthError } from "@/shared/errors.ts"
import { HeadersBuilder } from "@aura-stack/router"
import { secureApiHeaders } from "@/shared/headers.ts"
Expand All @@ -18,6 +18,7 @@ import type {
JoseInstance,
GetProviderTokensStatefulReturn,
} from "@/@types/index.ts"
import { revokeProviderToken } from "@/shared/utils/revoke-token.ts"

export const createStatelessStrategy = <DefaultUser extends User = User>({
config,
Expand Down Expand Up @@ -330,6 +331,35 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
}
}

const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise<Headers> => {
const cookieName = `${cookies().accessToken.name}.${oauthId}`
const cookie = getCookie(headers, cookieName)
const provider = oauth[oauthId]

if (!provider) {
throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" })
}

if (!disconnect) {
const decodedToken = await jwt.verifyToken(cookie)
const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken)

logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", {
structuredData: { provider: oauthId, hasAccessToken: !!tokens.accessToken },
})

await revokeProviderToken(provider, tokens.accessToken)

logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", {
structuredData: { provider: oauthId },
})
}
Comment thread
halvaradop marked this conversation as resolved.
const builder = new HeadersBuilder(secureApiHeaders)
.setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes))
.toHeaders()
return toUnionHeaders(builder, headers)
}
Comment thread
halvaradop marked this conversation as resolved.

// JWT strategy: stateless tokens cannot be revoked server-side
const revokeSession = async (_sessionId: string): Promise<void> => {}

Expand All @@ -339,5 +369,5 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
return cookieConfig.clear()
}

return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, destroySession }
return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, revokeToken, destroySession }
}
10 changes: 10 additions & 0 deletions packages/core/src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export const AuraErrorCode = {
* Database Errors
*/
DATABASE_TOKEN_HASH_NOT_FOUND: "DATABASE_TOKEN_HASH_NOT_FOUND",

OAUTH_UNLINKED_ACCOUNT_ERROR: "OAUTH_UNLINKED_ACCOUNT_ERROR",
} as const

export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode]
Expand Down Expand Up @@ -899,6 +901,14 @@ export const ERROR_CATALOG: Record<AuraErrorCode, CatalogEntry> = {
"The session verification query completed successfully, but the database returned a nullish record matching the provided session token hash identifier reference.",
userMessage: "The session was not found in the database. Please sign in again.",
},
OAUTH_UNLINKED_ACCOUNT_ERROR: {
type: "AUTH_FLOW",
statusCode: 400,
name: "OAuthError",
message:
"No linked connection exists between the selected OAuth provider and the active user session. The revocation or unlinking operation was aborted.",
userMessage: "The specified identity provider is not connected to your account.",
},
}

export interface AuraErrorOptions extends ErrorOptions {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,12 @@ export const logMessages = {
msgId: "STATELESS_GET_PROVIDER_TOKENS_ERROR",
message: "Error occurred during stateless getProviderTokens",
},
OAUTH_UNLINKED_ACCOUNT_ERROR: {
facility: 4,
severity: "error",
msgId: "OAUTH_UNLINKED_ACCOUNT_ERROR",
message: "Error occurred while unlinking OAuth account from user",
},
} as const

export const createLogEntry = <T extends keyof typeof logMessages>(key: T, overrides?: Partial<SyslogOptions>): SyslogOptions => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export const shouldRefresh = (payload: OAuthTokenPayload, refreshWindow: number)

export const merge = (origin: Record<string, unknown>, source: Record<string, unknown>) => {
for (const key in source) {
if (source[key] instanceof Object && key in origin) {
if (source[key] instanceof Object && !(source[key] instanceof Array) && key in origin) {
Object.assign(source[key], merge(origin[key] as Record<string, unknown>, source[key] as Record<string, unknown>))
}
}
Expand Down
Loading
Loading