diff --git a/CHANGELOG.md b/CHANGELOG.md index 821e5462b..87e546d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) - Hardened post-auth redirects with an explicit same-origin `redirect` callback in the NextAuth config, and switched the legacy `/~/...` URL rewrite from a 308 to a 301. [#1161](https://github.com/sourcebot-dev/sourcebot/pull/1161) +- Made the Auth.js JWT session lifetime and OAuth token TTLs configurable via `AUTH_SESSION_MAX_AGE_SECONDS`, `AUTH_SESSION_UPDATE_AGE_SECONDS`, `OAUTH_AUTHORIZATION_CODE_TTL_SECONDS`, `OAUTH_ACCESS_TOKEN_TTL_SECONDS`, and `OAUTH_REFRESH_TOKEN_TTL_SECONDS`. Defaults preserve existing behavior. [#1162](https://github.com/sourcebot-dev/sourcebot/pull/1162) ### Fixed - Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155) diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 33a1bff2e..875a32dbe 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -13,6 +13,11 @@ The following environment variables allow you to configure your Sourcebot deploy | `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |
Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/overview) for more info
| | `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/overview) for more info
| | `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` |Used to validate login session cookies
| +| `AUTH_SESSION_MAX_AGE_SECONDS` | `2592000` (30 days) |Relative time from now in seconds when to expire the session.
| +| `AUTH_SESSION_UPDATE_AGE_SECONDS` | `86400` (1 day) |How often the session should be updated in seconds. If set to `0`, session is updated every time.
| +| `OAUTH_AUTHORIZATION_CODE_TTL_SECONDS` | `600` (10 minutes) |Lifetime of an OAuth authorization code, in seconds.
| +| `OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `3600` (1 hour) |Lifetime of an OAuth access token, in seconds.
| +| `OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `7776000` (90 days) |Lifetime of an OAuth refresh token, in seconds.
| | `AUTH_URL` | - |URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`.
| | `CONFIG_PATH` | `-` |The container relative path to the declarative configuration file. See [this doc](/docs/configuration/declarative-config) for more info.
| | `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` |The root data directory in which all data written to disk by Sourcebot will be located.
| diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index d954c8c42..460afc29b 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -139,6 +139,41 @@ const options = { AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'), AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'), + /** + * Relative time from now in seconds when to expire the session. + * + * @default 30 days + */ + AUTH_SESSION_MAX_AGE_SECONDS: numberSchema.default(60 * 60 * 24 * 30), + + /** + * How often the session should be updated in seconds. If set to 0, session is updated every time. + * + * @default 1 day + */ + AUTH_SESSION_UPDATE_AGE_SECONDS: numberSchema.default(60 * 60 * 24), + + /** + * Lifetime of an OAuth authorization code, in seconds. + * + * @default 10 minutes + */ + OAUTH_AUTHORIZATION_CODE_TTL_SECONDS: numberSchema.default(60 * 10), + + /** + * Lifetime of an OAuth access token, in seconds. + * + * @default 1 hour + */ + OAUTH_ACCESS_TOKEN_TTL_SECONDS: numberSchema.default(60 * 60), + + /** + * Lifetime of an OAuth refresh token, in seconds. + * + * @default 90 days + */ + OAUTH_REFRESH_TOKEN_TTL_SECONDS: numberSchema.default(60 * 60 * 24 * 90), + // Enterprise Auth AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING: booleanSchema diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index 5638b10fd..a5934ffd4 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -1,6 +1,6 @@ -import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SECONDS } from '@/ee/features/oauth/server'; +import { verifyAndExchangeCode, verifyAndRotateRefreshToken } from '@/ee/features/oauth/server'; import { apiHandler } from '@/lib/apiHandler'; -import { hasEntitlement } from '@sourcebot/shared'; +import { env, hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; @@ -59,7 +59,7 @@ export const POST = apiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: 'Bearer', - expires_in: ACCESS_TOKEN_TTL_SECONDS, + expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope: '', }); } @@ -91,7 +91,7 @@ export const POST = apiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: 'Bearer', - expires_in: ACCESS_TOKEN_TTL_SECONDS, + expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope: '', }); } diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 9ee2c1b05..9b5d93d90 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -148,6 +148,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: EncryptedPrismaAdapter(__unsafePrisma), session: { strategy: "jwt", + maxAge: env.AUTH_SESSION_MAX_AGE_SECONDS, + updateAge: env.AUTH_SESSION_UPDATE_AGE_SECONDS, }, trustHost: true, events: { diff --git a/packages/web/src/ee/features/oauth/server.test.ts b/packages/web/src/ee/features/oauth/server.test.ts index e6a118178..244749d6e 100644 --- a/packages/web/src/ee/features/oauth/server.test.ts +++ b/packages/web/src/ee/features/oauth/server.test.ts @@ -15,6 +15,11 @@ vi.mock('@sourcebot/shared', () => ({ generateOAuthRefreshToken: vi.fn(() => ({ token: 'sbor_newrefresh', hash: 'newrefresh' })), OAUTH_ACCESS_TOKEN_PREFIX: 'sboa_', OAUTH_REFRESH_TOKEN_PREFIX: 'sbor_', + env: { + OAUTH_AUTHORIZATION_CODE_TTL_SECONDS: 60 * 10, + OAUTH_ACCESS_TOKEN_TTL_SECONDS: 60 * 60, + OAUTH_REFRESH_TOKEN_TTL_SECONDS: 60 * 60 * 24 * 90, + }, })); const VALID_CODE_HASH = 'validcode'; diff --git a/packages/web/src/ee/features/oauth/server.ts b/packages/web/src/ee/features/oauth/server.ts index 059e8fb0d..ac5676b23 100644 --- a/packages/web/src/ee/features/oauth/server.ts +++ b/packages/web/src/ee/features/oauth/server.ts @@ -3,6 +3,7 @@ import 'server-only'; import { __unsafePrisma } from '@/prisma'; import { Prisma } from '@prisma/client'; import { + env, generateOAuthRefreshToken, generateOAuthToken, hashSecret, @@ -11,12 +12,6 @@ import { } from '@sourcebot/shared'; import crypto from 'crypto'; -const AUTH_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour -const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days - -export const ACCESS_TOKEN_TTL_SECONDS = Math.floor(ACCESS_TOKEN_TTL_MS / 1000); - // Generates a random authorization code, hashes it, and stores it alongside the // PKCE code challenge. Returns the raw code to be sent to the client. export async function generateAndStoreAuthCode({ @@ -43,7 +38,7 @@ export async function generateAndStoreAuthCode({ redirectUri, codeChallenge, resource, - expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS), + expiresAt: new Date(Date.now() + env.OAUTH_AUTHORIZATION_CODE_TTL_SECONDS * 1000), }, }); @@ -124,7 +119,7 @@ export async function verifyAndExchangeCode({ clientId, userId: authCode.userId, resource: authCode.resource, - expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS), + expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), }, }), __unsafePrisma.oAuthRefreshToken.create({ @@ -133,12 +128,12 @@ export async function verifyAndExchangeCode({ clientId, userId: authCode.userId, resource: authCode.resource, - expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), + expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), }, }), ]); - return { token, refreshToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS }; } // Verifies a refresh token, rotates it, and issues a new access token + refresh token. @@ -189,7 +184,7 @@ export async function verifyAndRotateRefreshToken({ clientId, userId: existing.userId, resource: existing.resource, - expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS), + expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), }, }), __unsafePrisma.oAuthRefreshToken.create({ @@ -198,12 +193,12 @@ export async function verifyAndRotateRefreshToken({ clientId, userId: existing.userId, resource: existing.resource, - expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), + expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), }, }), ]); - return { token, refreshToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS }; } // Revokes an access token or refresh token by hashing it and deleting the DB record.