diff --git a/src/server/implementation/index.ts b/src/server/implementation/index.ts index 3cb4bfc..61f1d6d 100644 --- a/src/server/implementation/index.ts +++ b/src/server/implementation/index.ts @@ -26,7 +26,7 @@ import { GenericActionCtxWithAuthConfig, } from "../types.js"; import { requireEnv } from "../utils.js"; -import { ActionCtx, MutationCtx, Tokens } from "./types.js"; +import { ActionCtx, MutationCtx, Tokens, SessionInfoWithTokens } from "./types.js"; export { authTables, Doc, Tokens } from "./types.js"; import { LOG_LEVELS, @@ -40,6 +40,7 @@ import { callInvalidateSessions, callModifyAccount, callRetreiveAccountWithCredentials, + callSignIn, callSignOut, callUserOAuth, callVerifierSignature, @@ -54,6 +55,7 @@ import { oAuthConfigToInternalProvider, } from "../oauth/convexAuth.js"; import { handleOAuth } from "../oauth/callback.js"; +import { requireSiteUrl } from "./runtimeEnv.js"; export { getAuthSessionId } from "./sessions.js"; /** @@ -197,13 +199,12 @@ export function convexAuth(config_: ConvexAuthConfig) { path: "/.well-known/openid-configuration", method: "GET", handler: httpActionGeneric(async () => { + const siteUrl = requireSiteUrl(config); return new Response( JSON.stringify({ - issuer: requireEnv("CONVEX_SITE_URL"), - jwks_uri: - requireEnv("CONVEX_SITE_URL") + "/.well-known/jwks.json", - authorization_endpoint: - requireEnv("CONVEX_SITE_URL") + "/oauth/authorize", + issuer: siteUrl, + jwks_uri: siteUrl + "/.well-known/jwks.json", + authorization_endpoint: siteUrl + "/oauth/authorize", }), { status: 200, @@ -251,10 +252,11 @@ export function convexAuth(config_: ConvexAuthConfig) { const provider = getProviderOrThrow( providerId, ) as OAuthConfig; + const siteUrl = requireSiteUrl(config); const { redirect, cookies, signature } = await getAuthorizationUrl({ provider: await oAuthConfigToInternalProvider(provider), - cookies: defaultCookiesOptions(providerId), + cookies: defaultCookiesOptions(providerId, siteUrl), }); await callVerifierSignature(ctx, { @@ -319,13 +321,15 @@ export function convexAuth(config_: ConvexAuthConfig) { } } + const siteUrl = requireSiteUrl(config); + try { const { profile, tokens, signature } = await handleOAuth( Object.fromEntries(params.entries()), cookies, { provider: await oAuthConfigToInternalProvider(provider), - cookies: defaultCookiesOptions(provider.id), + cookies: defaultCookiesOptions(provider.id, siteUrl), }, ); @@ -603,6 +607,35 @@ export async function retrieveAccount< return result; } +/** + * Issue an access token and refresh token for a user by creating + * or reusing a session. + */ +export async function issueTokens< + DataModel extends GenericDataModel = GenericDataModel, +>( + ctx: GenericActionCtx, + args: { + userId: GenericId<"users">; + sessionId?: GenericId<"authSessions">; + }, +): Promise { + const actionCtx = ctx as unknown as ActionCtx; + const result = await callSignIn(actionCtx, { + userId: args.userId, + sessionId: args.sessionId, + generateTokens: true, + }); + if (result.tokens === null) { + throw new Error("Failed to generate tokens for session"); + } + return { + userId: result.userId, + sessionId: result.sessionId, + tokens: result.tokens, + }; +} + /** * Use this function to modify the account credentials * from a [`ConvexCredentials`](https://labs.convex.dev/auth/api_reference/providers/ConvexCredentials) diff --git a/src/server/implementation/mutations/createAccountFromCredentials.ts b/src/server/implementation/mutations/createAccountFromCredentials.ts index 3d93a27..001d5de 100644 --- a/src/server/implementation/mutations/createAccountFromCredentials.ts +++ b/src/server/implementation/mutations/createAccountFromCredentials.ts @@ -5,6 +5,7 @@ import { ConvexCredentialsConfig } from "../../types.js"; import { upsertUserAndAccount } from "../users.js"; import { getAuthSessionId } from "../sessions.js"; import { LOG_LEVELS, logWithLevel, maybeRedact } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const createAccountFromCredentialsArgs = v.object({ provider: v.string(), @@ -94,5 +95,6 @@ export const callCreateAccountFromCredentials = async ( type: "createAccountFromCredentials", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/createVerificationCode.ts b/src/server/implementation/mutations/createVerificationCode.ts index 24d5e13..bbff8a2 100644 --- a/src/server/implementation/mutations/createVerificationCode.ts +++ b/src/server/implementation/mutations/createVerificationCode.ts @@ -5,6 +5,7 @@ import { EmailConfig, PhoneConfig } from "../../types.js"; import { getAccountOrThrow, upsertUserAndAccount } from "../users.js"; import { getAuthSessionId } from "../sessions.js"; import { LOG_LEVELS, logWithLevel, sha256 } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const createVerificationCodeArgs = v.object({ accountId: v.optional(v.id("authAccounts")), @@ -80,6 +81,7 @@ export const callCreateVerificationCode = async ( type: "createVerificationCode", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/index.ts b/src/server/implementation/mutations/index.ts index c3fae2f..43c3a81 100644 --- a/src/server/implementation/mutations/index.ts +++ b/src/server/implementation/mutations/index.ts @@ -32,6 +32,10 @@ import { import * as Provider from "../provider.js"; import { verifierImpl } from "./verifier.js"; import { LOG_LEVELS, logWithLevel } from "../utils.js"; +import { + AuthRuntimeEnv, + mergeRuntimeEnv, +} from "../runtimeEnv.js"; export { callInvalidateSessions } from "./invalidateSessions.js"; export { callModifyAccount } from "./modifyAccount.js"; export { callRetreiveAccountWithCredentials } from "./retrieveAccountWithCredentials.js"; @@ -45,6 +49,11 @@ export { callRefreshSession } from "./refreshSession.js"; export { callSignOut } from "./signOut.js"; export { callSignIn } from "./signIn.js"; +const runtimeEnvArgs = v.object({ + siteUrl: v.optional(v.string()), + customAuthSiteUrl: v.optional(v.string()), +}); + export const storeArgs = v.object({ args: v.union( v.object({ @@ -94,6 +103,7 @@ export const storeArgs = v.object({ ...invalidateSessionsArgs.fields, }), ), + env: v.optional(runtimeEnvArgs), }); export const storeImpl = async ( @@ -103,19 +113,42 @@ export const storeImpl = async ( config: Provider.Config, ) => { const args = fnArgs.args; + const runtimeEnv: AuthRuntimeEnv = mergeRuntimeEnv( + config as any, + fnArgs.env, + ); + if (process.env.AUTH_LOG_LEVEL === "DEBUG") { + console.debug("storeImpl runtimeEnv", { + pid: process.pid, + type: args.type, + runtimeEnv, + }); + } logWithLevel(LOG_LEVELS.INFO, `\`auth:store\` type: ${args.type}`); switch (args.type) { case "signIn": { - return signInImpl(ctx, args, config); + return signInImpl(ctx, args, config, runtimeEnv); } case "signOut": { return signOutImpl(ctx); } case "refreshSession": { - return refreshSessionImpl(ctx, args, getProviderOrThrow, config); + return refreshSessionImpl( + ctx, + args, + getProviderOrThrow, + config, + runtimeEnv, + ); } case "verifyCodeAndSignIn": { - return verifyCodeAndSignInImpl(ctx, args, getProviderOrThrow, config); + return verifyCodeAndSignInImpl( + ctx, + args, + getProviderOrThrow, + config, + runtimeEnv, + ); } case "verifier": { return verifierImpl(ctx); diff --git a/src/server/implementation/mutations/invalidateSessions.ts b/src/server/implementation/mutations/invalidateSessions.ts index 32e37dd..d09d55f 100644 --- a/src/server/implementation/mutations/invalidateSessions.ts +++ b/src/server/implementation/mutations/invalidateSessions.ts @@ -2,6 +2,7 @@ import { Infer, v } from "convex/values"; import { deleteSession } from "../sessions.js"; import { ActionCtx, MutationCtx } from "../types.js"; import { LOG_LEVELS, logWithLevel } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const invalidateSessionsArgs = v.object({ userId: v.id("users"), @@ -17,6 +18,7 @@ export const callInvalidateSessions = async ( type: "invalidateSessions", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/modifyAccount.ts b/src/server/implementation/mutations/modifyAccount.ts index 9262d95..8be56d2 100644 --- a/src/server/implementation/mutations/modifyAccount.ts +++ b/src/server/implementation/mutations/modifyAccount.ts @@ -2,6 +2,7 @@ import { Infer, v } from "convex/values"; import { ActionCtx, MutationCtx } from "../types.js"; import { GetProviderOrThrowFunc, hash } from "../provider.js"; import { LOG_LEVELS, logWithLevel, maybeRedact } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const modifyAccountArgs = v.object({ provider: v.string(), @@ -47,5 +48,6 @@ export const callModifyAccount = async ( type: "modifyAccount", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/refreshSession.ts b/src/server/implementation/mutations/refreshSession.ts index 829c1d5..8ce448c 100644 --- a/src/server/implementation/mutations/refreshSession.ts +++ b/src/server/implementation/mutations/refreshSession.ts @@ -11,6 +11,10 @@ import { refreshTokenIfValid, } from "../refreshTokens.js"; import { generateTokensForSession } from "../sessions.js"; +import { + AuthRuntimeEnv, + collectRuntimeEnv, +} from "../runtimeEnv.js"; export const refreshSessionArgs = v.object({ refreshToken: v.string(), @@ -26,6 +30,7 @@ export async function refreshSessionImpl( args: Infer, getProviderOrThrow: Provider.GetProviderOrThrowFunc, config: Provider.Config, + runtimeEnv: AuthRuntimeEnv, ): Promise { const { refreshToken } = args; const { refreshTokenId, sessionId: tokenSessionId } = @@ -63,7 +68,7 @@ export async function refreshSessionImpl( await ctx.db.patch(refreshTokenId, { firstUsedTime: Date.now(), }); - const result = await generateTokensForSession(ctx, config, { + const result = await generateTokensForSession(ctx, config, runtimeEnv, { userId, sessionId, issuedRefreshTokenId: null, @@ -95,7 +100,7 @@ export async function refreshSessionImpl( `Token ${maybeRedact(validationResult.refreshTokenDoc._id)} is parent of active refresh token ${maybeRedact(activeRefreshToken._id)}, so returning that token`, ); - const result = await generateTokensForSession(ctx, config, { + const result = await generateTokensForSession(ctx, config, runtimeEnv, { userId, sessionId, issuedRefreshTokenId: activeRefreshToken._id, @@ -106,7 +111,7 @@ export async function refreshSessionImpl( // Check if within reuse window if (tokenFirstUsed + REFRESH_TOKEN_REUSE_WINDOW_MS > Date.now()) { - const result = await generateTokensForSession(ctx, config, { + const result = await generateTokensForSession(ctx, config, runtimeEnv, { userId, sessionId, issuedRefreshTokenId: null, @@ -150,5 +155,6 @@ export const callRefreshSession = async ( type: "refreshSession", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/retrieveAccountWithCredentials.ts b/src/server/implementation/mutations/retrieveAccountWithCredentials.ts index 0e40af0..0fff651 100644 --- a/src/server/implementation/mutations/retrieveAccountWithCredentials.ts +++ b/src/server/implementation/mutations/retrieveAccountWithCredentials.ts @@ -7,6 +7,7 @@ import { } from "../rateLimit.js"; import * as Provider from "../provider.js"; import { LOG_LEVELS, logWithLevel, maybeRedact } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const retrieveAccountWithCredentialsArgs = v.object({ provider: v.string(), @@ -74,5 +75,6 @@ export const callRetreiveAccountWithCredentials = async ( type: "retrieveAccountWithCredentials", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/signIn.ts b/src/server/implementation/mutations/signIn.ts index 3f4ee59..0772bc8 100644 --- a/src/server/implementation/mutations/signIn.ts +++ b/src/server/implementation/mutations/signIn.ts @@ -6,6 +6,10 @@ import { maybeGenerateTokensForSession, } from "../sessions.js"; import { LOG_LEVELS, logWithLevel } from "../utils.js"; +import { + AuthRuntimeEnv, + collectRuntimeEnv, +} from "../runtimeEnv.js"; export const signInArgs = v.object({ userId: v.id("users"), @@ -19,6 +23,7 @@ export async function signInImpl( ctx: MutationCtx, args: Infer, config: Provider.Config, + runtimeEnv: AuthRuntimeEnv, ): Promise { logWithLevel(LOG_LEVELS.DEBUG, "signInImpl args:", args); const { userId, sessionId: existingSessionId, generateTokens } = args; @@ -28,6 +33,7 @@ export async function signInImpl( return await maybeGenerateTokensForSession( ctx, config, + runtimeEnv, userId, sessionId, generateTokens, @@ -43,5 +49,6 @@ export const callSignIn = async ( type: "signIn", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/signOut.ts b/src/server/implementation/mutations/signOut.ts index 4b828d8..87a0b3c 100644 --- a/src/server/implementation/mutations/signOut.ts +++ b/src/server/implementation/mutations/signOut.ts @@ -1,6 +1,7 @@ import { GenericId } from "convex/values"; import { ActionCtx, MutationCtx } from "../types.js"; import { deleteSession, getAuthSessionId } from "../sessions.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; type ReturnType = { userId: GenericId<"users">; @@ -24,5 +25,6 @@ export const callSignOut = async (ctx: ActionCtx): Promise => { args: { type: "signOut", }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/userOAuth.ts b/src/server/implementation/mutations/userOAuth.ts index 9f33208..00545ad 100644 --- a/src/server/implementation/mutations/userOAuth.ts +++ b/src/server/implementation/mutations/userOAuth.ts @@ -4,6 +4,7 @@ import * as Provider from "../provider.js"; import { OAuthConfig } from "@auth/core/providers/oauth.js"; import { upsertUserAndAccount } from "../users.js"; import { generateRandomString, logWithLevel, sha256 } from "../utils.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; const OAUTH_SIGN_IN_EXPIRATION_MS = 1000 * 60 * 2; // 2 minutes @@ -78,5 +79,6 @@ export const callUserOAuth = async ( type: "userOAuth", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/verifier.ts b/src/server/implementation/mutations/verifier.ts index ec7dfbc..7363301 100644 --- a/src/server/implementation/mutations/verifier.ts +++ b/src/server/implementation/mutations/verifier.ts @@ -1,6 +1,7 @@ import { GenericId } from "convex/values"; import { ActionCtx, MutationCtx } from "../types.js"; import { getAuthSessionId } from "../sessions.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; type ReturnType = GenericId<"authVerifiers">; @@ -15,5 +16,6 @@ export const callVerifier = async (ctx: ActionCtx): Promise => { args: { type: "verifier", }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/verifierSignature.ts b/src/server/implementation/mutations/verifierSignature.ts index 9bd5eb5..b1f529d 100644 --- a/src/server/implementation/mutations/verifierSignature.ts +++ b/src/server/implementation/mutations/verifierSignature.ts @@ -1,5 +1,6 @@ import { GenericId, Infer, v } from "convex/values"; import { ActionCtx, MutationCtx } from "../types.js"; +import { collectRuntimeEnv } from "../runtimeEnv.js"; export const verifierSignatureArgs = v.object({ verifier: v.string(), @@ -29,5 +30,6 @@ export const callVerifierSignature = async ( type: "verifierSignature", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/mutations/verifyCodeAndSignIn.ts b/src/server/implementation/mutations/verifyCodeAndSignIn.ts index 473c1dd..259cbf5 100644 --- a/src/server/implementation/mutations/verifyCodeAndSignIn.ts +++ b/src/server/implementation/mutations/verifyCodeAndSignIn.ts @@ -14,6 +14,10 @@ import { import { ConvexAuthConfig } from "../../types.js"; import { LOG_LEVELS, logWithLevel, sha256 } from "../utils.js"; import { upsertUserAndAccount } from "../users.js"; +import { + AuthRuntimeEnv, + collectRuntimeEnv, +} from "../runtimeEnv.js"; export const verifyCodeAndSignInArgs = v.object({ params: v.any(), @@ -30,6 +34,7 @@ export async function verifyCodeAndSignInImpl( args: Infer, getProviderOrThrow: Provider.GetProviderOrThrowFunc, config: Provider.Config, + runtimeEnv: AuthRuntimeEnv, ): Promise { logWithLevel(LOG_LEVELS.DEBUG, "verifyCodeAndSignInImpl args:", { params: { email: args.params.email, phone: args.params.phone }, @@ -76,6 +81,7 @@ export async function verifyCodeAndSignInImpl( return await maybeGenerateTokensForSession( ctx, config, + runtimeEnv, userId, sessionId, generateTokens, @@ -91,6 +97,7 @@ export const callVerifyCodeAndSignIn = async ( type: "verifyCodeAndSignIn", ...args, }, + env: collectRuntimeEnv(), }); }; diff --git a/src/server/implementation/runtimeEnv.ts b/src/server/implementation/runtimeEnv.ts new file mode 100644 index 0000000..ad02be9 --- /dev/null +++ b/src/server/implementation/runtimeEnv.ts @@ -0,0 +1,79 @@ +import { ConvexAuthMaterializedConfig } from "../types.js"; + +export type AuthRuntimeEnv = { + siteUrl?: string; + customAuthSiteUrl?: string; +}; + +export function collectRuntimeEnv(): AuthRuntimeEnv | undefined { + const siteUrl = process.env.CONVEX_SITE_URL; + const customAuthSiteUrl = process.env.CUSTOM_AUTH_SITE_URL; + if (process.env.AUTH_LOG_LEVEL === "DEBUG") { + console.debug("collectRuntimeEnv", { + pid: process.pid, + siteUrl, + customAuthSiteUrl, + }); + } + if (siteUrl === undefined && customAuthSiteUrl === undefined) { + return undefined; + } + return { + siteUrl: siteUrl ?? undefined, + customAuthSiteUrl: customAuthSiteUrl ?? undefined, + }; +} + +export function mergeRuntimeEnv( + config: Pick, + runtime?: AuthRuntimeEnv, +): AuthRuntimeEnv { + return { + siteUrl: runtime?.siteUrl ?? config.siteUrl, + customAuthSiteUrl: + runtime?.customAuthSiteUrl ?? + config.customAuthSiteUrl ?? + runtime?.siteUrl ?? + config.siteUrl, + }; +} + +export function requireSiteUrl( + config: Pick, + runtime?: AuthRuntimeEnv, +): string { + const siteUrl = + runtime?.siteUrl ?? + config.siteUrl ?? + process.env.CONVEX_SITE_URL ?? + process.env.CUSTOM_AUTH_SITE_URL; + if (siteUrl === undefined && process.env.AUTH_LOG_LEVEL === "DEBUG") { + console.debug("requireSiteUrl missing", { + pid: process.pid, + runtime, + configSiteUrl: config.siteUrl, + envSiteUrl: process.env.CONVEX_SITE_URL, + envCustomAuthSiteUrl: process.env.CUSTOM_AUTH_SITE_URL, + }); + } + if (siteUrl === undefined) { + throw new Error( + "Missing environment variable `CONVEX_SITE_URL`. Set it in Convex or pass `siteUrl` to `convexAuth`.", + ); + } + return siteUrl; +} + +export function resolveAuthBaseUrl( + config: Pick, + runtime?: AuthRuntimeEnv, +): string { + return ( + runtime?.customAuthSiteUrl ?? + config.customAuthSiteUrl ?? + runtime?.siteUrl ?? + config.siteUrl ?? + process.env.CUSTOM_AUTH_SITE_URL ?? + requireSiteUrl(config, runtime) + ); +} diff --git a/src/server/implementation/sessions.ts b/src/server/implementation/sessions.ts index 3f4557b..bfa0ce7 100644 --- a/src/server/implementation/sessions.ts +++ b/src/server/implementation/sessions.ts @@ -15,12 +15,14 @@ import { formatRefreshToken, deleteAllRefreshTokens, } from "./refreshTokens.js"; +import { AuthRuntimeEnv } from "./runtimeEnv.js"; const DEFAULT_SESSION_TOTAL_DURATION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days export async function maybeGenerateTokensForSession( ctx: MutationCtx, config: ConvexAuthConfig, + runtimeEnv: AuthRuntimeEnv, userId: GenericId<"users">, sessionId: GenericId<"authSessions">, generateTokens: boolean, @@ -29,7 +31,7 @@ export async function maybeGenerateTokensForSession( userId, sessionId, tokens: generateTokens - ? await generateTokensForSession(ctx, config, { + ? await generateTokensForSession(ctx, config, runtimeEnv, { userId, sessionId, issuedRefreshTokenId: null, @@ -57,6 +59,7 @@ export async function createNewAndDeleteExistingSession( export async function generateTokensForSession( ctx: MutationCtx, config: ConvexAuthConfig, + runtimeEnv: AuthRuntimeEnv, args: { userId: GenericId<"users">; sessionId: GenericId<"authSessions">; @@ -74,7 +77,7 @@ export async function generateTokensForSession( args.parentRefreshTokenId, )); const result = { - token: await generateToken(ids, config), + token: await generateToken(ids, config, runtimeEnv), refreshToken: formatRefreshToken(refreshTokenId, args.sessionId), }; logWithLevel( diff --git a/src/server/implementation/signIn.ts b/src/server/implementation/signIn.ts index ea0b45c..b97a989 100644 --- a/src/server/implementation/signIn.ts +++ b/src/server/implementation/signIn.ts @@ -20,9 +20,9 @@ import { callVerifyCodeAndSignIn, } from "./mutations/index.js"; import { redirectAbsoluteUrl, setURLSearchParam } from "./redirects.js"; -import { requireEnv } from "../utils.js"; import { OAuth2Config, OIDCConfig } from "@auth/core/providers/oauth.js"; import { generateRandomString } from "./utils.js"; +import { resolveAuthBaseUrl } from "./runtimeEnv.js"; const DEFAULT_EMAIL_VERIFICATION_CODE_DURATION_S = 60 * 60 * 24; // 24 hours @@ -237,7 +237,7 @@ async function handleOAuthProvider( }; } const redirect = new URL( - (process.env.CUSTOM_AUTH_SITE_URL ?? requireEnv("CONVEX_SITE_URL")) + `/api/auth/signin/${provider.id}`, + resolveAuthBaseUrl(ctx.auth.config) + `/api/auth/signin/${provider.id}`, ); const verifier = await callVerifier(ctx); redirect.searchParams.set("code", verifier); diff --git a/src/server/implementation/tokens.ts b/src/server/implementation/tokens.ts index 3701f24..5fbfaf1 100644 --- a/src/server/implementation/tokens.ts +++ b/src/server/implementation/tokens.ts @@ -3,6 +3,7 @@ import { ConvexAuthConfig } from "../index.js"; import { SignJWT, importPKCS8 } from "jose"; import { requireEnv } from "../utils.js"; import { TOKEN_SUB_CLAIM_DIVIDER } from "./utils.js"; +import { AuthRuntimeEnv, requireSiteUrl } from "./runtimeEnv.js"; const DEFAULT_JWT_DURATION_MS = 1000 * 60 * 60; // 1 hour @@ -12,17 +13,19 @@ export async function generateToken( sessionId: GenericId<"authSessions">; }, config: ConvexAuthConfig, + runtimeEnv: AuthRuntimeEnv, ) { const privateKey = await importPKCS8(requireEnv("JWT_PRIVATE_KEY"), "RS256"); const expirationTime = new Date( Date.now() + (config.jwt?.durationMs ?? DEFAULT_JWT_DURATION_MS), ); + const siteUrl = requireSiteUrl(config, runtimeEnv); return await new SignJWT({ sub: args.userId + TOKEN_SUB_CLAIM_DIVIDER + args.sessionId, }) .setProtectedHeader({ alg: "RS256" }) .setIssuedAt() - .setIssuer(requireEnv("CONVEX_SITE_URL")) + .setIssuer(siteUrl) .setAudience("convex") .setExpirationTime(expirationTime) .sign(privateKey); diff --git a/src/server/index.ts b/src/server/index.ts index e3a1cce..4a99122 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -18,6 +18,7 @@ export { createAccount, retrieveAccount, signInViaProvider, + issueTokens, invalidateSessions, modifyAccountCredentials, Tokens, diff --git a/src/server/oauth/convexAuth.ts b/src/server/oauth/convexAuth.ts index f6166dd..1769e6c 100644 --- a/src/server/oauth/convexAuth.ts +++ b/src/server/oauth/convexAuth.ts @@ -32,28 +32,31 @@ export function getAuthorizationSignature({ function oauthStateCookieName( type: "state" | "pkce" | "nonce", providerId: string, + siteUrl?: string, ) { - return (!isLocalHost(process.env.CONVEX_SITE_URL) ? "__Host-" : "") + providerId + "OAuth" + type; + const host = siteUrl ?? process.env.CONVEX_SITE_URL; + return (!isLocalHost(host) ? "__Host-" : "") + providerId + "OAuth" + type; } export const defaultCookiesOptions: ( providerId: string, -) => Record = (providerId) => { + siteUrl?: string, +) => Record = (providerId, siteUrl) => { return { pkceCodeVerifier: { - name: oauthStateCookieName("pkce", providerId), + name: oauthStateCookieName("pkce", providerId, siteUrl), options: { ...SHARED_COOKIE_OPTIONS, }, }, state: { - name: oauthStateCookieName("state", providerId), + name: oauthStateCookieName("state", providerId, siteUrl), options: { ...SHARED_COOKIE_OPTIONS, }, }, nonce: { - name: oauthStateCookieName("nonce", providerId), + name: oauthStateCookieName("nonce", providerId, siteUrl), options: { ...SHARED_COOKIE_OPTIONS, }, @@ -165,4 +168,4 @@ export async function oAuthConfigToInternalProvider(config: OAuthConfig): P }, configSource: "provided", }; -} \ No newline at end of file +} diff --git a/src/server/provider_utils.ts b/src/server/provider_utils.ts index 5680649..94e9d27 100644 --- a/src/server/provider_utils.ts +++ b/src/server/provider_utils.ts @@ -43,6 +43,11 @@ export function configDefaults(config_: ConvexAuthConfig) { .map((p) => p.extraProviders) .flat() .filter((p) => p !== undefined); + const siteUrl = config_.siteUrl ?? process.env.CONVEX_SITE_URL ?? undefined; + const customAuthSiteUrl = + config_.customAuthSiteUrl ?? + process.env.CUSTOM_AUTH_SITE_URL ?? + siteUrl; return { ...config, extraProviders: materializeProviders(extraProviders), @@ -52,6 +57,8 @@ export function configDefaults(config_: ConvexAuthConfig) { brandColor: "", buttonText: "", }, + siteUrl, + customAuthSiteUrl, }; } diff --git a/src/server/types.ts b/src/server/types.ts index 0b32c1a..1d45751 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -28,6 +28,17 @@ export type ConvexAuthConfig = { * - `@convex-dev/auth/providers/` */ providers: AuthProviderConfig[]; + /** + * Override the Convex site URL used when generating JWTs and exposing + * OpenID configuration. If not provided, the library falls back to the + * `CONVEX_SITE_URL` environment variable. + */ + siteUrl?: string; + /** + * Override the URL used for OAuth callbacks and sign-in redirects. + * Defaults to `CUSTOM_AUTH_SITE_URL` (when set) or `siteUrl`. + */ + customAuthSiteUrl?: string; /** * Theme used for emails. * See [Auth.js theme docs](https://authjs.dev/reference/core/types#theme). @@ -364,7 +375,12 @@ export type GenericActionCtxWithAuthConfig = export type ConvexAuthMaterializedConfig = { providers: AuthProviderMaterializedConfig[]; theme: Theme; -} & Pick; + siteUrl?: string; + customAuthSiteUrl?: string; +} & Pick< + ConvexAuthConfig, + "session" | "jwt" | "signIn" | "callbacks" +>; /** * Materialized Auth.js provider config. diff --git a/test/convex/auth.ts b/test/convex/auth.ts index c97b669..c5b0163 100644 --- a/test/convex/auth.ts +++ b/test/convex/auth.ts @@ -5,8 +5,9 @@ import Resend from "@auth/core/providers/resend"; import Apple from "@auth/core/providers/apple"; import { Anonymous } from "@convex-dev/auth/providers/Anonymous"; import { Password } from "@convex-dev/auth/providers/Password"; -import { ConvexError } from "convex/values"; -import { convexAuth } from "@convex-dev/auth/server"; +import { ConvexError, v } from "convex/values"; +import { convexAuth, issueTokens as issueTokensHelper } from "@convex-dev/auth/server"; +import { action } from "./_generated/server"; import { ResendOTP } from "./otp/ResendOTP"; import { TwilioOTP } from "./otp/TwilioOTP"; import { TwilioVerify } from "./otp/TwilioVerify"; @@ -71,3 +72,13 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ Anonymous, ], }); + +export const issueTokens = action({ + args: { + userId: v.id("users"), + sessionId: v.optional(v.id("authSessions")), + }, + handler: async (ctx, args) => { + return await issueTokensHelper(ctx, args); + }, +}); diff --git a/test/convex/sessions.test.ts b/test/convex/sessions.test.ts index 17f2f4f..5fb6ce6 100644 --- a/test/convex/sessions.test.ts +++ b/test/convex/sessions.test.ts @@ -1,4 +1,5 @@ import { convexTest, TestConvex } from "convex-test"; +import { decodeJwt } from "jose"; import { expect, test, vi } from "vitest"; import { api } from "./_generated/api"; import schema from "./schema"; @@ -228,3 +229,44 @@ function setupEnv() { process.env.JWKS = JWKS; process.env.AUTH_LOG_LEVEL = "ERROR"; } + +test("issueTokens helper mints tokens with issuer set", async () => { + setupEnv(); + const siteUrl = process.env.CONVEX_SITE_URL!; + const t = convexTest(schema); + + const { tokens: initialTokens } = await t.action(api.auth.signIn, { + provider: "password", + params: { email: "sarah@gmail.com", password: "44448888", flow: "signUp" }, + }); + expect(initialTokens).not.toBeNull(); + + const { userId, sessionId } = await t.run(async (ctx) => { + const account = await ctx.db + .query("authAccounts") + .withIndex("providerAndAccountId", (q) => + q.eq("provider", "password").eq("providerAccountId", "sarah@gmail.com"), + ) + .unique(); + if (account === null) { + throw new Error("Account not created"); + } + const [session] = await ctx.db + .query("authSessions") + .withIndex("userId", (q) => q.eq("userId", account.userId)) + .collect(); + if (session === undefined) { + throw new Error("Session not found"); + } + return { userId: account.userId, sessionId: session._id }; + }); + + const minted = await t.action(api.auth.issueTokens, { + userId, + sessionId, + }); + + expect(minted.tokens.token).not.toBe(initialTokens!.token); + const claims = decodeJwt(minted.tokens.token); + expect(claims.iss).toBe(siteUrl); +}); diff --git a/test/package-lock.json b/test/package-lock.json index 7b9ef81..8b3c160 100644 --- a/test/package-lock.json +++ b/test/package-lock.json @@ -60,7 +60,7 @@ }, "..": { "name": "@convex-dev/auth", - "version": "0.0.87", + "version": "0.0.90", "license": "Apache-2.0", "dependencies": { "@oslojs/crypto": "^1.0.1", diff --git a/test/package.json b/test/package.json index 0f2bdb8..115afed 100644 --- a/test/package.json +++ b/test/package.json @@ -11,8 +11,8 @@ "build": "tsc && vite build", "lint": "tsc && tsc -p convex/tsconfig.json && eslint . --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", - "test": "vitest", - "test:once": "vitest run", + "test": "vitest run", + "test:watch": "vitest", "test:debug": "vitest --inspect-brk --no-file-parallelism", "test:coverage": "vitest run --coverage" },