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/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_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..d1c4330 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,10 +83,16 @@ 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, + 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_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..89d561e 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,10 +117,16 @@ 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, + 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/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() } diff --git a/src/core/routeHandlers/oauth.ts b/src/core/routeHandlers/oauth.ts index 8cc5f92..6ec600b 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": { @@ -66,6 +82,7 @@ export function OAuthHandlers( secret, successRedirectPath, errorRedirectPath, + additionalScope, ) } default: 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 = {