From 55def269bbfb89d782cca01a22e68cc436ffa5c9 Mon Sep 17 00:00:00 2001 From: Dalibor Belic Date: Wed, 13 Aug 2025 21:50:12 +0200 Subject: [PATCH 1/4] feat(oauth): add support for additional scope in oauth flow add optional additionalScope parameter to oauth functions to allow requesting extra permissions store additional scope in cookie and pass it through authorization and callback flows --- package.json | 2 ++ src/client/index.ts | 18 +++++++++----- src/client/oauth.ts | 6 +++++ src/client/signin.ts | 3 ++- .../protocols/oauth/oauth2_authorization.ts | 10 ++++++-- src/core/protocols/oauth/oauth2_callback.ts | 4 +++- .../protocols/oauth/oidc_authorization.ts | 10 ++++++-- src/core/protocols/oauth/oidc_callback.ts | 4 +++- src/core/routeHandlers/oauth.ts | 24 +++++++++++++++---- 9 files changed, 64 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index bca7d90..b3503e5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@tsconfig/strictest": "^2.0.5", "@types/bun": "latest", "@types/jsonwebtoken": "^9.0.7", + "@types/js-cookie": "^3.0.6", "cross-env": "^7.0.3", "git-cliff": "2.7.0", "globals": "^15.14.0", @@ -88,6 +89,7 @@ "@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/server": "^13.1.0", "jose": "6.0.8", + "js-cookie": "3.0.5", "oauth4webapi": "^3.1.4", "qs-esm": "7.0.2", "uuid": "11.1.0" diff --git a/src/client/index.ts b/src/client/index.ts index 95107a9..896ad2d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,17 +1,17 @@ +import { MissingPayloadAuthBaseURL } from "../core/errors/consoleErrors.js" import { - resetPassword, forgotPassword, recoverPassword, - type PasswordResetPayload, + resetPassword, type ForgotPasswordPayload, type PasswordRecoverPayload, + type PasswordResetPayload, } from "./password.js" import { refresh } from "./refresh.js" -import { signin } from "./signin.js" import { register } from "./register.js" -import { getSession, getClientSession } from "./session.js" +import { getClientSession, getSession } from "./session.js" +import { signin } from "./signin.js" import { signout } from "./signout.js" -import { MissingPayloadAuthBaseURL } from "../core/errors/consoleErrors.js" class AuthClient { private baseURL: string @@ -32,10 +32,16 @@ class AuthClient { (process.env.NEXT_PUBLIC_PAYLOAD_AUTH_URL as string) } - signin() { + /** + * Sign in a user + * @param additionalScope - Additional scope to request + * @returns The sign in response + */ + signin(additionalScope?: string) { return signin({ name: this.name, baseURL: this.baseURL, + additionalScope, }) } register() { diff --git a/src/client/oauth.ts b/src/client/oauth.ts index 0c6c5f0..f5582a8 100644 --- a/src/client/oauth.ts +++ b/src/client/oauth.ts @@ -1,8 +1,11 @@ /// /// +import Cookies from "js-cookie" + type BaseOptions = { name: string baseURL: string + additionalScope?: string } export type OauthProvider = @@ -22,6 +25,9 @@ export type OauthProvider = | "okta" export const oauth = (options: BaseOptions, provider: OauthProvider): void => { + const additionalScope = options.additionalScope || "" + Cookies.set("oauth_scope", additionalScope, { expires: 1 / 288, path: "/" }) + const oauthURL = `${options.baseURL}/api/${options.name}/oauth/authorization/${provider}` window.location.href = oauthURL } diff --git a/src/client/signin.ts b/src/client/signin.ts index af6ff46..a7ae363 100644 --- a/src/client/signin.ts +++ b/src/client/signin.ts @@ -1,8 +1,9 @@ -import { passwordSignin, type PasswordSigninPayload } from "./password.js" import { oauth, type OauthProvider } from "./oauth.js" +import { passwordSignin, type PasswordSigninPayload } from "./password.js" interface BaseOptions { name: string baseURL: string + additionalScope?: string } export const signin = (options: BaseOptions) => { diff --git a/src/core/protocols/oauth/oauth2_authorization.ts b/src/core/protocols/oauth/oauth2_authorization.ts index d0303ec..ba727b0 100644 --- a/src/core/protocols/oauth/oauth2_authorization.ts +++ b/src/core/protocols/oauth/oauth2_authorization.ts @@ -1,13 +1,14 @@ import * as oauth from "oauth4webapi" +import type { PayloadRequest } from "payload" import type { OAuth2ProviderConfig } from "../../../types.js" import { getCallbackURL } from "../../utils/cb.js" -import type { PayloadRequest } from "payload" export async function OAuth2Authorization( pluginType: string, request: PayloadRequest, providerConfig: OAuth2ProviderConfig, clientOrigin?: string | undefined, + additionalScope?: string, ): Promise { const callback_url = getCallbackURL( request.payload.config.serverURL, @@ -32,7 +33,12 @@ export async function OAuth2Authorization( authorizationURL.searchParams.set("client_id", client.client_id) authorizationURL.searchParams.set("redirect_uri", callback_url.toString()) authorizationURL.searchParams.set("response_type", "code") - authorizationURL.searchParams.set("scope", scope as string) + if (additionalScope) { + const totalScope = `${scope} ${additionalScope}` + authorizationURL.searchParams.set("scope", totalScope) + } else { + authorizationURL.searchParams.set("scope", scope as string) + } authorizationURL.searchParams.set("code_challenge", code_challenge) authorizationURL.searchParams.set( "code_challenge_method", diff --git a/src/core/protocols/oauth/oauth2_callback.ts b/src/core/protocols/oauth/oauth2_callback.ts index 521a637..187e7d7 100644 --- a/src/core/protocols/oauth/oauth2_callback.ts +++ b/src/core/protocols/oauth/oauth2_callback.ts @@ -18,6 +18,7 @@ export async function OAuth2Callback( secret: string, successRedirectPath: string, errorRedirectPath: string, + additionalScope?: string, ): Promise { const parsedCookies = parseCookies(request.headers) @@ -82,7 +83,8 @@ export async function OAuth2Callback( email: userInfo.email, name: userInfo.name ?? "", sub: userInfo.sub, - scope: providerConfig.scope, + scope: + providerConfig.scope + (additionalScope ? ` ${additionalScope}` : ""), issuer: providerConfig.authorization_server.issuer, picture: userInfo.picture ?? "", access_token: token_result.access_token, diff --git a/src/core/protocols/oauth/oidc_authorization.ts b/src/core/protocols/oauth/oidc_authorization.ts index 79980a7..bec97f4 100644 --- a/src/core/protocols/oauth/oidc_authorization.ts +++ b/src/core/protocols/oauth/oidc_authorization.ts @@ -1,12 +1,13 @@ import * as oauth from "oauth4webapi" +import type { PayloadRequest } from "payload" import type { OIDCProviderConfig } from "../../../types.js" import { getCallbackURL } from "../../utils/cb.js" -import type { PayloadRequest } from "payload" export async function OIDCAuthorization( pluginType: string, request: PayloadRequest, providerConfig: OIDCProviderConfig, + additionalScope?: string, ): Promise { const callback_url = getCallbackURL( request.payload.config.serverURL, @@ -33,7 +34,12 @@ export async function OIDCAuthorization( authorizationURL.searchParams.set("client_id", client.client_id) authorizationURL.searchParams.set("redirect_uri", callback_url.toString()) authorizationURL.searchParams.set("response_type", "code") - authorizationURL.searchParams.set("scope", scope as string) + if (additionalScope) { + const totalScope = `${scope} ${additionalScope}` + authorizationURL.searchParams.set("scope", totalScope) + } else { + authorizationURL.searchParams.set("scope", scope as string) + } authorizationURL.searchParams.set("code_challenge", code_challenge) authorizationURL.searchParams.set( "code_challenge_method", diff --git a/src/core/protocols/oauth/oidc_callback.ts b/src/core/protocols/oauth/oidc_callback.ts index 5f5bcec..df55f46 100644 --- a/src/core/protocols/oauth/oidc_callback.ts +++ b/src/core/protocols/oauth/oidc_callback.ts @@ -23,6 +23,7 @@ export async function OIDCCallback( secret: string, successRedirectPath: string, errorRedirectPath: string, + additionalScope?: string, ): Promise { const parsedCookies = parseCookies(request.headers) @@ -116,7 +117,8 @@ export async function OIDCCallback( email: result.email, name: result.name ?? "", sub: result.sub, - scope: providerConfig.scope, + scope: + providerConfig.scope + (additionalScope ? ` ${additionalScope}` : ""), issuer: providerConfig.issuer, picture: result.picture ?? "", access_token: token_result.access_token, diff --git a/src/core/routeHandlers/oauth.ts b/src/core/routeHandlers/oauth.ts index 8cc5f92..6b57f76 100644 --- a/src/core/routeHandlers/oauth.ts +++ b/src/core/routeHandlers/oauth.ts @@ -1,14 +1,15 @@ import type { PayloadRequest } from "payload" +import { parseCookies } from "payload" import type { OAuthProviderConfig } from "../../types.js" import { InvalidOAuthAlgorithm, InvalidOAuthResource, InvalidProvider, } from "../errors/consoleErrors.js" -import { OIDCAuthorization } from "../protocols/oauth/oidc_authorization.js" import { OAuth2Authorization } from "../protocols/oauth/oauth2_authorization.js" -import { OIDCCallback } from "../protocols/oauth/oidc_callback.js" import { OAuth2Callback } from "../protocols/oauth/oauth2_callback.js" +import { OIDCAuthorization } from "../protocols/oauth/oidc_authorization.js" +import { OIDCCallback } from "../protocols/oauth/oidc_callback.js" export function OAuthHandlers( pluginType: string, @@ -30,13 +31,27 @@ export function OAuthHandlers( const resource = request.routeParams?.resource as string + const headers = request.headers + const cookies = parseCookies(headers) + const additionalScope = cookies.get("oauth_scope") + switch (resource) { case "authorization": switch (provider.algorithm) { case "oidc": - return OIDCAuthorization(pluginType, request, provider) + return OIDCAuthorization( + pluginType, + request, + provider, + additionalScope, + ) case "oauth2": - return OAuth2Authorization(pluginType, request, provider) + return OAuth2Authorization( + pluginType, + request, + provider, + additionalScope, + ) default: throw new InvalidOAuthAlgorithm() } @@ -53,6 +68,7 @@ export function OAuthHandlers( secret, successRedirectPath, errorRedirectPath, + additionalScope, ) } case "oauth2": { From de506e54d651c9ba177bee7d53641ca2b0099ece Mon Sep 17 00:00:00 2001 From: Dalibor Belic Date: Wed, 13 Aug 2025 21:59:29 +0200 Subject: [PATCH 2/4] feat(oauth): add additionalScope parameter to OAuthHandlers --- src/core/routeHandlers/oauth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/routeHandlers/oauth.ts b/src/core/routeHandlers/oauth.ts index 6b57f76..6ec600b 100644 --- a/src/core/routeHandlers/oauth.ts +++ b/src/core/routeHandlers/oauth.ts @@ -82,6 +82,7 @@ export function OAuthHandlers( secret, successRedirectPath, errorRedirectPath, + additionalScope, ) } default: From 8a6fb82edaf37cd2c533ee68f5d07b4608890dc0 Mon Sep 17 00:00:00 2001 From: Dalibor Belic Date: Thu, 21 Aug 2025 11:05:32 +0200 Subject: [PATCH 3/4] feat(oauth): add refresh_token and expires_in fields to oauth flow add support for refresh tokens and token expiration in oauth authentication update account info interface and collection schema accordingly --- src/collection/index.ts | 8 ++++++++ src/core/protocols/oauth/oauth2_callback.ts | 5 +++++ src/core/protocols/oauth/oauth_authentication.ts | 6 ++++++ src/core/protocols/oauth/oidc_callback.ts | 5 +++++ src/types.ts | 2 ++ 5 files changed, 26 insertions(+) diff --git a/src/collection/index.ts b/src/collection/index.ts index d9f42e4..f448ec1 100644 --- a/src/collection/index.ts +++ b/src/collection/index.ts @@ -148,6 +148,14 @@ export const withAccountCollection = ( name: "access_token", type: "text", }, + { + name: "refresh_token", + type: "text", + }, + { + name: "expires_in", + type: "number", + }, { name: "passkey", type: "group", diff --git a/src/core/protocols/oauth/oauth2_callback.ts b/src/core/protocols/oauth/oauth2_callback.ts index 187e7d7..d1c4330 100644 --- a/src/core/protocols/oauth/oauth2_callback.ts +++ b/src/core/protocols/oauth/oauth2_callback.ts @@ -88,6 +88,11 @@ export async function OAuth2Callback( issuer: providerConfig.authorization_server.issuer, picture: userInfo.picture ?? "", access_token: token_result.access_token, + refresh_token: token_result.refresh_token ?? "", + expires_in: + typeof token_result.expires_in === "number" + ? token_result.expires_in + : undefined, } return await OAuthAuthentication( diff --git a/src/core/protocols/oauth/oauth_authentication.ts b/src/core/protocols/oauth/oauth_authentication.ts index 09044b9..10ffa1c 100644 --- a/src/core/protocols/oauth/oauth_authentication.ts +++ b/src/core/protocols/oauth/oauth_authentication.ts @@ -32,6 +32,8 @@ export async function OAuthAuthentication( issuer: string picture?: string | undefined access_token: string + refresh_token?: string + expires_in?: number }, ): Promise { const { @@ -42,6 +44,8 @@ export async function OAuthAuthentication( issuer, picture, access_token, + refresh_token, + expires_in, } = account const { payload } = request @@ -86,6 +90,8 @@ export async function OAuthAuthentication( picture: picture, issuerName: issuer, access_token, + refresh_token, + expires_in, } const accountRecords = await payload.find({ diff --git a/src/core/protocols/oauth/oidc_callback.ts b/src/core/protocols/oauth/oidc_callback.ts index df55f46..89d561e 100644 --- a/src/core/protocols/oauth/oidc_callback.ts +++ b/src/core/protocols/oauth/oidc_callback.ts @@ -122,6 +122,11 @@ export async function OIDCCallback( issuer: providerConfig.issuer, picture: result.picture ?? "", access_token: token_result.access_token, + refresh_token: token_result.refresh_token ?? "", + expires_in: + typeof token_result.expires_in === "number" + ? token_result.expires_in + : undefined, } return await OAuthAuthentication( diff --git a/src/types.ts b/src/types.ts index 858e3ac..7e55707 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,6 +111,8 @@ export interface AccountInfo { backedUp: boolean } access_token?: string + refresh_token?: string + expires_in?: number } export type PasswordProviderConfig = { From 0bb04afe54086c45db0e03618bd19e66d3b2be12 Mon Sep 17 00:00:00 2001 From: Dalibor Belic Date: Thu, 21 Aug 2025 11:06:00 +0200 Subject: [PATCH 4/4] fix(password): correct typo in variable name isVerified The variable name was misspelled as 'isVerifed' in two places. This commit fixes the typo to ensure consistent naming and prevent potential confusion. --- src/core/protocols/password.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/core/protocols/password.ts b/src/core/protocols/password.ts index 2444c21..5d244d6 100644 --- a/src/core/protocols/password.ts +++ b/src/core/protocols/password.ts @@ -1,4 +1,7 @@ import { parseCookies, type PayloadRequest } from "payload" +import { v4 as uuid } from "uuid" +import { APP_COOKIE_SUFFIX } from "../../constants.js" +import { SuccessKind } from "../../types.js" import { EmailAlreadyExistError, InvalidCredentials, @@ -8,16 +11,13 @@ import { UnauthorizedAPIRequest, UserNotFoundAPIError, } from "../errors/apiErrors.js" -import { hashPassword, verifyPassword } from "../utils/password.js" -import { SuccessKind } from "../../types.js" -import { ephemeralCode, verifyEphemeralCode } from "../utils/hash.js" -import { APP_COOKIE_SUFFIX } from "../../constants.js" import { createSessionCookies, invalidateOAuthCookies, verifySessionCookie, } from "../utils/cookies.js" -import { v4 as uuid } from "uuid" +import { ephemeralCode, verifyEphemeralCode } from "../utils/hash.js" +import { hashPassword, verifyPassword } from "../utils/password.js" import { removeExpiredSessions } from "../utils/session.js" const redirectWithSession = async ( @@ -92,13 +92,13 @@ export const PasswordSignin = async ( return new InvalidCredentials() } - const isVerifed = await verifyPassword( + const isVerified = await verifyPassword( body.password, userRecord.hashedPassword, userRecord.hashSalt, userRecord.hashIterations, ) - if (!isVerifed) { + if (!isVerified) { return new InvalidCredentials() } @@ -456,13 +456,13 @@ export const ResetPassword = async ( } const user = docs[0] - const isVerifed = await verifyPassword( + const isVerified = await verifyPassword( body.currentPassword, user.hashedPassword, user.hashSalt, user.hashIterations, ) - if (!isVerifed) { + if (!isVerified) { return new InvalidCredentials() }