fix(auth): restore BetterAuth OIDC flow across api/web/compose
This commit is contained in:
@@ -70,9 +70,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
|||||||
OIDC_CLIENT_ID=your-client-id-here
|
OIDC_CLIENT_ID=your-client-id-here
|
||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
# Redirect URI must match what's configured in Authentik
|
# Redirect URI must match what's configured in Authentik
|
||||||
# Development: http://localhost:3001/auth/callback/authentik
|
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
# Production: https://api.mosaicstack.dev/auth/callback/authentik
|
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# Authentik PostgreSQL Database
|
# Authentik PostgreSQL Database
|
||||||
AUTHENTIK_POSTGRES_USER=authentik
|
AUTHENTIK_POSTGRES_USER=authentik
|
||||||
@@ -116,6 +116,9 @@ JWT_EXPIRATION=24h
|
|||||||
# This is used by BetterAuth for session management and CSRF protection
|
# This is used by BetterAuth for session management and CSRF protection
|
||||||
# Example: openssl rand -base64 32
|
# Example: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
||||||
|
# Optional explicit BetterAuth origin for callback/error URL generation.
|
||||||
|
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
|
||||||
|
BETTER_AUTH_URL=
|
||||||
|
|
||||||
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
|
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
|
||||||
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
|
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ vi.mock("better-auth/adapters/prisma", () => ({
|
|||||||
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
|
import {
|
||||||
|
isOidcEnabled,
|
||||||
|
validateOidcConfig,
|
||||||
|
createAuth,
|
||||||
|
getTrustedOrigins,
|
||||||
|
getBetterAuthBaseUrl,
|
||||||
|
} from "./auth.config";
|
||||||
|
|
||||||
describe("auth.config", () => {
|
describe("auth.config", () => {
|
||||||
// Store original env vars to restore after each test
|
// Store original env vars to restore after each test
|
||||||
@@ -32,6 +38,7 @@ describe("auth.config", () => {
|
|||||||
delete process.env.OIDC_CLIENT_SECRET;
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
delete process.env.OIDC_REDIRECT_URI;
|
delete process.env.OIDC_REDIRECT_URI;
|
||||||
delete process.env.NODE_ENV;
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.BETTER_AUTH_URL;
|
||||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||||
delete process.env.NEXT_PUBLIC_API_URL;
|
delete process.env.NEXT_PUBLIC_API_URL;
|
||||||
delete process.env.TRUSTED_ORIGINS;
|
delete process.env.TRUSTED_ORIGINS;
|
||||||
@@ -95,7 +102,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_ISSUER is missing", () => {
|
it("should throw when OIDC_ISSUER is missing", () => {
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
|
||||||
@@ -104,7 +111,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_CLIENT_ID is missing", () => {
|
it("should throw when OIDC_CLIENT_ID is missing", () => {
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
|
||||||
});
|
});
|
||||||
@@ -112,7 +119,7 @@ describe("auth.config", () => {
|
|||||||
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
|
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
|
||||||
});
|
});
|
||||||
@@ -146,7 +153,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = " ";
|
process.env.OIDC_ISSUER = " ";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
|
||||||
});
|
});
|
||||||
@@ -155,7 +162,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
|
||||||
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
|
||||||
@@ -165,7 +172,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
@@ -189,30 +196,30 @@ describe("auth.config", () => {
|
|||||||
expect(() => validateOidcConfig()).toThrow("Parse error:");
|
expect(() => validateOidcConfig()).toThrow("Parse error:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
|
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).toThrow(
|
expect(() => validateOidcConfig()).toThrow(
|
||||||
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
|
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"'
|
||||||
);
|
);
|
||||||
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
|
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
|
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
|
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback";
|
||||||
|
|
||||||
expect(() => validateOidcConfig()).not.toThrow();
|
expect(() => validateOidcConfig()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should warn but not throw when using localhost in production", () => {
|
it("should warn but not throw when using localhost in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -226,7 +233,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should warn but not throw when using 127.0.0.1 in production", () => {
|
it("should warn but not throw when using 127.0.0.1 in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -240,7 +247,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should not warn about localhost when not in production", () => {
|
it("should not warn about localhost when not in production", () => {
|
||||||
process.env.NODE_ENV = "development";
|
process.env.NODE_ENV = "development";
|
||||||
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -265,16 +272,19 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
createAuth(mockPrisma);
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
expect(mockGenericOAuth).toHaveBeenCalledOnce();
|
||||||
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
|
||||||
config: Array<{ pkce?: boolean }>;
|
config: Array<{ pkce?: boolean; redirectURI?: string }>;
|
||||||
};
|
};
|
||||||
expect(callArgs.config[0].pkce).toBe(true);
|
expect(callArgs.config[0].pkce).toBe(true);
|
||||||
|
expect(callArgs.config[0].redirectURI).toBe(
|
||||||
|
"https://app.example.com/auth/oauth2/callback/authentik"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not call genericOAuth when OIDC is disabled", () => {
|
it("should not call genericOAuth when OIDC is disabled", () => {
|
||||||
@@ -290,7 +300,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_CLIENT_ID deliberately not set
|
// OIDC_CLIENT_ID deliberately not set
|
||||||
|
|
||||||
// validateOidcConfig will throw first, so we need to bypass it
|
// validateOidcConfig will throw first, so we need to bypass it
|
||||||
@@ -307,7 +317,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_CLIENT_SECRET deliberately not set
|
// OIDC_CLIENT_SECRET deliberately not set
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
@@ -318,7 +328,7 @@ describe("auth.config", () => {
|
|||||||
process.env.OIDC_ENABLED = "true";
|
process.env.OIDC_ENABLED = "true";
|
||||||
process.env.OIDC_CLIENT_ID = "test-client-id";
|
process.env.OIDC_CLIENT_ID = "test-client-id";
|
||||||
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
|
||||||
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
|
||||||
// OIDC_ISSUER deliberately not set
|
// OIDC_ISSUER deliberately not set
|
||||||
|
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
@@ -354,8 +364,7 @@ describe("auth.config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
|
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
|
||||||
process.env.TRUSTED_ORIGINS =
|
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
||||||
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
|
|
||||||
|
|
||||||
const origins = getTrustedOrigins();
|
const origins = getTrustedOrigins();
|
||||||
|
|
||||||
@@ -364,8 +373,7 @@ describe("auth.config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
|
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
|
||||||
process.env.TRUSTED_ORIGINS =
|
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
||||||
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
|
|
||||||
|
|
||||||
const origins = getTrustedOrigins();
|
const origins = getTrustedOrigins();
|
||||||
|
|
||||||
@@ -552,6 +560,7 @@ describe("auth.config", () => {
|
|||||||
|
|
||||||
it("should set secure cookie attribute to true in production", () => {
|
it("should set secure cookie attribute to true in production", () => {
|
||||||
process.env.NODE_ENV = "production";
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
const mockPrisma = {} as PrismaClient;
|
const mockPrisma = {} as PrismaClient;
|
||||||
createAuth(mockPrisma);
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
@@ -624,4 +633,69 @@ describe("auth.config", () => {
|
|||||||
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
|
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getBetterAuthBaseUrl", () => {
|
||||||
|
it("should prefer BETTER_AUTH_URL when set", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
|
|
||||||
|
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
|
||||||
|
|
||||||
|
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is invalid", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "not-a-url";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is missing in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when base URL is not https in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.BETTER_AUTH_URL = "http://api.example.com";
|
||||||
|
|
||||||
|
expect(() => getBetterAuthBaseUrl()).toThrow(
|
||||||
|
"BetterAuth base URL must use https in production"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAuth - baseURL wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBetterAuth.mockClear();
|
||||||
|
mockPrismaAdapter.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
|
||||||
|
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
|
||||||
|
|
||||||
|
const mockPrisma = {} as PrismaClient;
|
||||||
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||||
|
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||||
|
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
|
||||||
|
|
||||||
|
const mockPrisma = {} as PrismaClient;
|
||||||
|
createAuth(mockPrisma);
|
||||||
|
|
||||||
|
expect(mockBetterAuth).toHaveBeenCalledOnce();
|
||||||
|
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
|
||||||
|
expect(config.baseURL).toBe("https://api.fallback.dev");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ const REQUIRED_OIDC_ENV_VARS = [
|
|||||||
"OIDC_REDIRECT_URI",
|
"OIDC_REDIRECT_URI",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve BetterAuth base URL from explicit auth URL or API URL.
|
||||||
|
* BetterAuth uses this to generate absolute callback/error URLs.
|
||||||
|
*/
|
||||||
|
export function getBetterAuthBaseUrl(): string | undefined {
|
||||||
|
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
if (!configured || configured.trim() === "") {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(configured);
|
||||||
|
} catch (urlError: unknown) {
|
||||||
|
const detail = urlError instanceof Error ? urlError.message : String(urlError);
|
||||||
|
throw new Error(
|
||||||
|
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error(
|
||||||
|
`BetterAuth base URL must use https in production. Current value: "${configured}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.origin;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if OIDC authentication is enabled via environment variable
|
* Check if OIDC authentication is enabled via environment variable
|
||||||
*/
|
*/
|
||||||
@@ -59,17 +94,17 @@ export function validateOidcConfig(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
|
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
|
||||||
validateRedirectUri();
|
validateRedirectUri();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the OIDC_REDIRECT_URI environment variable.
|
* Validates the OIDC_REDIRECT_URI environment variable.
|
||||||
* - Must be a parseable URL
|
* - Must be a parseable URL
|
||||||
* - Path must start with /auth/callback
|
* - Path must start with /auth/oauth2/callback
|
||||||
* - Warns (but does not throw) if using localhost in production
|
* - Warns (but does not throw) if using localhost in production
|
||||||
*
|
*
|
||||||
* @throws Error if URL is invalid or path does not start with /auth/callback
|
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
|
||||||
*/
|
*/
|
||||||
function validateRedirectUri(): void {
|
function validateRedirectUri(): void {
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
@@ -86,14 +121,14 @@ function validateRedirectUri(): void {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
|
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
|
||||||
`Parse error: ${detail}. ` +
|
`Parse error: ${detail}. ` +
|
||||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.pathname.startsWith("/auth/callback")) {
|
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
|
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
|
||||||
`Example: "https://app.example.com/auth/callback/authentik".`
|
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +155,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
const clientId = process.env.OIDC_CLIENT_ID;
|
const clientId = process.env.OIDC_CLIENT_ID;
|
||||||
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||||
const issuer = process.env.OIDC_ISSUER;
|
const issuer = process.env.OIDC_ISSUER;
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
|
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
|
||||||
@@ -130,6 +166,9 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
if (!issuer) {
|
if (!issuer) {
|
||||||
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
|
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
|
||||||
}
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -139,6 +178,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
discoveryUrl: `${issuer}.well-known/openid-configuration`,
|
||||||
|
redirectURI: redirectUri,
|
||||||
pkce: true,
|
pkce: true,
|
||||||
scopes: ["openid", "profile", "email"],
|
scopes: ["openid", "profile", "email"],
|
||||||
},
|
},
|
||||||
@@ -203,7 +243,10 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
// Validate OIDC configuration at startup - fail fast if misconfigured
|
// Validate OIDC configuration at startup - fail fast if misconfigured
|
||||||
validateOidcConfig();
|
validateOidcConfig();
|
||||||
|
|
||||||
|
const baseURL = getBetterAuthBaseUrl();
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
|
baseURL,
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
|
|||||||
@@ -102,11 +102,46 @@ describe("AuthController", () => {
|
|||||||
expect(err).toBeInstanceOf(HttpException);
|
expect(err).toBeInstanceOf(HttpException);
|
||||||
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
expect((err as HttpException).getResponse()).toBe(
|
expect((err as HttpException).getResponse()).toBe(
|
||||||
"Unable to complete authentication. Please try again in a moment.",
|
"Unable to complete authentication. Please try again in a moment."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve better-call status and body for handler APIError", async () => {
|
||||||
|
const apiError = {
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
body: {
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
code: "INVALID_OAUTH_CONFIGURATION",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockNodeHandler.mockRejectedValueOnce(apiError);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
method: "POST",
|
||||||
|
url: "/auth/sign-in/oauth2",
|
||||||
|
headers: {},
|
||||||
|
ip: "192.168.1.10",
|
||||||
|
socket: { remoteAddress: "192.168.1.10" },
|
||||||
|
} as unknown as ExpressRequest;
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
headersSent: false,
|
||||||
|
} as unknown as ExpressResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
expect.unreachable("Expected HttpException to be thrown");
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(HttpException);
|
||||||
|
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
|
||||||
|
expect((err as HttpException).getResponse()).toMatchObject({
|
||||||
|
message: "Invalid OAuth configuration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should log warning and not throw when handler throws after headers sent", async () => {
|
it("should log warning and not throw when handler throws after headers sent", async () => {
|
||||||
const handlerError = new Error("Stream interrupted");
|
const handlerError = new Error("Stream interrupted");
|
||||||
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
mockNodeHandler.mockRejectedValueOnce(handlerError);
|
||||||
@@ -142,9 +177,7 @@ describe("AuthController", () => {
|
|||||||
headersSent: false,
|
headersSent: false,
|
||||||
} as unknown as ExpressResponse;
|
} as unknown as ExpressResponse;
|
||||||
|
|
||||||
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
|
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
|
||||||
HttpException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +220,7 @@ describe("AuthController", () => {
|
|||||||
OIDC_CLIENT_SECRET: "test-client-secret",
|
OIDC_CLIENT_SECRET: "test-client-secret",
|
||||||
OIDC_CLIENT_ID: "test-client-id",
|
OIDC_CLIENT_ID: "test-client-id",
|
||||||
OIDC_ISSUER: "https://auth.test.com/",
|
OIDC_ISSUER: "https://auth.test.com/",
|
||||||
OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
|
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
|
||||||
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
BETTER_AUTH_SECRET: "test-better-auth-secret",
|
||||||
JWT_SECRET: "test-jwt-secret",
|
JWT_SECRET: "test-jwt-secret",
|
||||||
CSRF_SECRET: "test-csrf-secret",
|
CSRF_SECRET: "test-csrf-secret",
|
||||||
@@ -296,11 +329,9 @@ describe("AuthController", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,22 +344,18 @@ describe("AuthController", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
|
||||||
const mockRequest = {};
|
const mockRequest = {};
|
||||||
|
|
||||||
|
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
||||||
UnauthorizedException,
|
"Missing authentication context"
|
||||||
);
|
|
||||||
expect(() => controller.getSession(mockRequest as never)).toThrow(
|
|
||||||
"Missing authentication context",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -401,9 +428,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
|
||||||
@@ -423,13 +448,9 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
// Ensure it does NOT contain the second IP in the extracted position
|
// Ensure it does NOT contain the second IP in the extracted position
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
|
||||||
expect.not.stringContaining("70.41.3.18"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract first IP from X-Forwarded-For as array", async () => {
|
it("should extract first IP from X-Forwarded-For as array", async () => {
|
||||||
@@ -449,9 +470,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
|
||||||
expect.stringContaining("203.0.113.50"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
|
||||||
@@ -471,9 +490,7 @@ describe("AuthController", () => {
|
|||||||
|
|
||||||
await controller.handleAuth(mockRequest, mockResponse);
|
await controller.handleAuth(mockRequest, mockResponse);
|
||||||
|
|
||||||
expect(debugSpy).toHaveBeenCalledWith(
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
|
||||||
expect.stringContaining("192.168.1.100"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
const mappedError = this.mapToHttpException(error);
|
||||||
|
if (mappedError) {
|
||||||
|
throw mappedError;
|
||||||
|
}
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Unable to complete authentication. Please try again in a moment.",
|
"Unable to complete authentication. Please try again in a moment.",
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
@@ -159,4 +164,45 @@ export class AuthController {
|
|||||||
// Fall back to direct IP
|
// Fall back to direct IP
|
||||||
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
|
||||||
|
* every failure into a generic 500.
|
||||||
|
*/
|
||||||
|
private mapToHttpException(error: unknown): HttpException | null {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = "statusCode" in error ? error.statusCode : undefined;
|
||||||
|
if (!this.isHttpStatus(statusCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
|
||||||
|
if (
|
||||||
|
responseBody !== undefined &&
|
||||||
|
responseBody !== null &&
|
||||||
|
(typeof responseBody === "string" || typeof responseBody === "object")
|
||||||
|
) {
|
||||||
|
return new HttpException(responseBody, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
"message" in error && typeof error.message === "string" && error.message.length > 0
|
||||||
|
? error.message
|
||||||
|
: "Authentication request failed";
|
||||||
|
return new HttpException(message, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHttpStatus(value: unknown): value is number {
|
||||||
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value >= 400 && value <= 599;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ describe("LoginPage", (): void => {
|
|||||||
|
|
||||||
expect(mockOAuth2).toHaveBeenCalledWith({
|
expect(mockOAuth2).toHaveBeenCalledWith({
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
callbackURL: "/",
|
callbackURL: "http://localhost:3000/",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,9 @@ function LoginPageContent(): ReactElement {
|
|||||||
const handleOAuthLogin = useCallback((providerId: string): void => {
|
const handleOAuthLogin = useCallback((providerId: string): void => {
|
||||||
setOauthLoading(providerId);
|
setOauthLoading(providerId);
|
||||||
setError(null);
|
setError(null);
|
||||||
signIn.oauth2({ providerId, callbackURL: "/" }).catch((err: unknown) => {
|
const callbackURL =
|
||||||
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||||
|
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-}
|
||||||
CSRF_SECRET: ${CSRF_SECRET}
|
CSRF_SECRET: ${CSRF_SECRET}
|
||||||
# External services
|
# External services
|
||||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT}
|
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT}
|
||||||
|
|||||||
@@ -362,12 +362,13 @@ services:
|
|||||||
OIDC_ISSUER: ${OIDC_ISSUER}
|
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik}
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-}
|
||||||
# Encryption (required for federation credentials/private keys)
|
# Encryption (required for federation credentials/private keys)
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
# Ollama (optional)
|
# Ollama (optional)
|
||||||
|
|||||||
@@ -378,12 +378,13 @@ services:
|
|||||||
OIDC_ISSUER: ${OIDC_ISSUER}
|
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/oauth2/callback/authentik}
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-}
|
||||||
# Security
|
# Security
|
||||||
CSRF_SECRET: ${CSRF_SECRET}
|
CSRF_SECRET: ${CSRF_SECRET}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ JWT_EXPIRATION=24h
|
|||||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic/
|
OIDC_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
OIDC_CLIENT_ID=prod-client-id
|
OIDC_CLIENT_ID=prod-client-id
|
||||||
OIDC_CLIENT_SECRET=prod-client-secret
|
OIDC_CLIENT_SECRET=prod-client-secret
|
||||||
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback
|
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compose Override for Production
|
### Compose Override for Production
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ OIDC_CLIENT_ID=your-client-id
|
|||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# Callback URL (must match Authentik configuration)
|
# Callback URL (must match Authentik configuration)
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Authentik Setup](2-authentik.md) for complete OIDC configuration.
|
See [Authentik Setup](2-authentik.md) for complete OIDC configuration.
|
||||||
@@ -229,7 +229,7 @@ JWT_EXPIRATION=24h
|
|||||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Cache
|
# Cache
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ Sign up at [goauthentik.io](https://goauthentik.io) for managed Authentik.
|
|||||||
4. **Configure Provider:**
|
4. **Configure Provider:**
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| ------------------------------ | ----------------------------------------------- |
|
| ------------------------------ | ------------------------------------------------------ |
|
||||||
| **Name** | Mosaic Stack |
|
| **Name** | Mosaic Stack |
|
||||||
| **Authorization flow** | default-provider-authorization-implicit-consent |
|
| **Authorization flow** | default-provider-authorization-implicit-consent |
|
||||||
| **Client type** | Confidential |
|
| **Client type** | Confidential |
|
||||||
| **Client ID** | (auto-generated, save this) |
|
| **Client ID** | (auto-generated, save this) |
|
||||||
| **Client Secret** | (auto-generated, save this) |
|
| **Client Secret** | (auto-generated, save this) |
|
||||||
| **Redirect URIs** | `http://localhost:3001/auth/callback` |
|
| **Redirect URIs** | `http://localhost:3001/auth/oauth2/callback/authentik` |
|
||||||
| **Scopes** | `openid`, `email`, `profile` |
|
| **Scopes** | `openid`, `email`, `profile` |
|
||||||
| **Subject mode** | Based on User's UUID |
|
| **Subject mode** | Based on User's UUID |
|
||||||
| **Include claims in id_token** | ✅ Enabled |
|
| **Include claims in id_token** | ✅ Enabled |
|
||||||
@@ -96,7 +96,7 @@ Update your `.env` file:
|
|||||||
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
|
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=<your-client-id-from-step-2>
|
OIDC_CLIENT_ID=<your-client-id-from-step-2>
|
||||||
OIDC_CLIENT_SECRET=<your-client-secret-from-step-2>
|
OIDC_CLIENT_SECRET=<your-client-secret-from-step-2>
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
@@ -113,7 +113,7 @@ For production deployments:
|
|||||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=prod-client-id
|
OIDC_CLIENT_ID=prod-client-id
|
||||||
OIDC_CLIENT_SECRET=prod-client-secret
|
OIDC_CLIENT_SECRET=prod-client-secret
|
||||||
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/callback
|
OIDC_REDIRECT_URI=https://mosaic.example.com/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
Update Authentik redirect URIs to match your production URL.
|
Update Authentik redirect URIs to match your production URL.
|
||||||
@@ -143,7 +143,7 @@ docker compose restart api
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initiate OIDC flow
|
# Initiate OIDC flow
|
||||||
curl http://localhost:3001/auth/callback/authentik
|
curl http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# This will return a redirect URL to Authentik
|
# This will return a redirect URL to Authentik
|
||||||
```
|
```
|
||||||
@@ -223,8 +223,8 @@ Customize Authentik's login page:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ensure exact match (including http vs https)
|
# Ensure exact match (including http vs https)
|
||||||
# In Authentik: http://localhost:3001/auth/callback
|
# In Authentik: http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
# In .env: OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error: "Invalid client credentials"
|
### Error: "Invalid client credentials"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ AUTHENTIK_PORT_HTTPS=9443
|
|||||||
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
|
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=your-client-id-here
|
OIDC_CLIENT_ID=your-client-id-here
|
||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bootstrap Credentials:**
|
**Bootstrap Credentials:**
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ Authorization: Bearer {session_token}
|
|||||||
OAuth callback handler for Authentik (and other OIDC providers).
|
OAuth callback handler for Authentik (and other OIDC providers).
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /auth/callback/authentik
|
GET /auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
@@ -226,7 +226,7 @@ This endpoint is called by the OIDC provider after successful authentication.
|
|||||||
1. User clicks "Sign in with Authentik"
|
1. User clicks "Sign in with Authentik"
|
||||||
2. Frontend redirects to Authentik
|
2. Frontend redirects to Authentik
|
||||||
3. User authenticates with Authentik
|
3. User authenticates with Authentik
|
||||||
4. Authentik redirects to /auth/callback/authentik
|
4. Authentik redirects to /auth/oauth2/callback/authentik
|
||||||
5. Server exchanges code for tokens
|
5. Server exchanges code for tokens
|
||||||
6. Server creates/updates user
|
6. Server creates/updates user
|
||||||
7. Server creates session
|
7. Server creates session
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ If using private registry images from `git.mosaicstack.dev`:
|
|||||||
OIDC_CLIENT_ID=<your-oidc-client-id>
|
OIDC_CLIENT_ID=<your-oidc-client-id>
|
||||||
OIDC_CLIENT_SECRET=<your-oidc-client-secret>
|
OIDC_CLIENT_SECRET=<your-oidc-client-secret>
|
||||||
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
||||||
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
|
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
OLLAMA_ENDPOINT=http://10.1.1.42:11434
|
OLLAMA_ENDPOINT=http://10.1.1.42:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ ENCRYPTION_KEY=<64-char-hex> # openssl rand -hex 32
|
|||||||
OIDC_CLIENT_ID=<from-authentik>
|
OIDC_CLIENT_ID=<from-authentik>
|
||||||
OIDC_CLIENT_SECRET=<from-authentik>
|
OIDC_CLIENT_SECRET=<from-authentik>
|
||||||
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
||||||
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
|
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# External Ollama
|
# External Ollama
|
||||||
OLLAMA_ENDPOINT=http://10.1.1.42:11434
|
OLLAMA_ENDPOINT=http://10.1.1.42:11434
|
||||||
@@ -352,7 +352,7 @@ Update environment variables:
|
|||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_APP_URL=https://mosaic.example.com
|
NEXT_PUBLIC_APP_URL=https://mosaic.example.com
|
||||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||||
OIDC_REDIRECT_URI=https://api.example.com/auth/callback/authentik
|
OIDC_REDIRECT_URI=https://api.example.com/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
### Resource Limits
|
### Resource Limits
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ sleep 30
|
|||||||
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.diversecanvas.com/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/callback/authentik
|
OIDC_REDIRECT_URI=https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using External PostgreSQL
|
### Using External PostgreSQL
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ States:
|
|||||||
Add `OIDC_REDIRECT_URI` to `REQUIRED_OIDC_ENV_VARS`. Add URL format validation:
|
Add `OIDC_REDIRECT_URI` to `REQUIRED_OIDC_ENV_VARS`. Add URL format validation:
|
||||||
|
|
||||||
- Must be a valid URL
|
- Must be a valid URL
|
||||||
- Path must start with `/auth/callback`
|
- Path must start with `/auth/oauth2/callback`
|
||||||
- Warn if using `localhost` in production
|
- Warn if using `localhost` in production
|
||||||
|
|
||||||
**Tests to add:** Missing var, invalid URL, invalid path, valid URL.
|
**Tests to add:** Missing var, invalid URL, invalid path, valid URL.
|
||||||
@@ -716,9 +716,9 @@ Browser NestJS API Authentik
|
|||||||
├────────────────────────────────────────────────────►│
|
├────────────────────────────────────────────────────►│
|
||||||
│ │ User authenticates│
|
│ │ User authenticates│
|
||||||
│◄────────────────────────────────────────────────────┤
|
│◄────────────────────────────────────────────────────┤
|
||||||
│ 302 → /auth/callback/authentik?code=X │
|
│ 302 → /auth/oauth2/callback/authentik?code=X │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ 5. GET /auth/callback/authentik?code=X │
|
│ 5. GET /auth/oauth2/callback/authentik?code=X │
|
||||||
├───────────────────────────►│ │
|
├───────────────────────────►│ │
|
||||||
│ BetterAuth exchanges code │
|
│ BetterAuth exchanges code │
|
||||||
│ ├───────────────────────►│
|
│ ├───────────────────────►│
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ To use the authentication system, configure these environment variables:
|
|||||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# JWT Session Management
|
# JWT Session Management
|
||||||
JWT_SECRET=change-this-to-a-random-secret-in-production
|
JWT_SECRET=change-this-to-a-random-secret-in-production
|
||||||
@@ -186,7 +186,7 @@ BetterAuth provides these endpoints automatically:
|
|||||||
- `POST /auth/sign-up` - User registration
|
- `POST /auth/sign-up` - User registration
|
||||||
- `POST /auth/sign-out` - Logout
|
- `POST /auth/sign-out` - Logout
|
||||||
- `GET /auth/session` - Get current session
|
- `GET /auth/session` - Get current session
|
||||||
- `GET /auth/callback/authentik` - OAuth callback handler
|
- `GET /auth/oauth2/callback/authentik` - OAuth callback handler
|
||||||
- `GET /auth/profile` - Get authenticated user profile (custom)
|
- `GET /auth/profile` - Get authenticated user profile (custom)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ All components must follow TDD (tests first), achieve 85%+ coverage, and use PDA
|
|||||||
### Existing Auth Implementation (from Issue #4)
|
### Existing Auth Implementation (from Issue #4)
|
||||||
|
|
||||||
- BetterAuth is configured in the API (`apps/api/src/auth/`)
|
- BetterAuth is configured in the API (`apps/api/src/auth/`)
|
||||||
- Endpoints: `/auth/callback/authentik`, `/auth/session`, `/auth/profile`
|
- Endpoints: `/auth/oauth2/callback/authentik`, `/auth/session`, `/auth/profile`
|
||||||
- Shared types available in `@mosaic/shared` package
|
- Shared types available in `@mosaic/shared` package
|
||||||
- Session-based auth with JWT tokens
|
- Session-based auth with JWT tokens
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ Based on existing backend (from Issue #4):
|
|||||||
- `GET /auth/session` - Get current session
|
- `GET /auth/session` - Get current session
|
||||||
- `GET /auth/profile` - Get user profile
|
- `GET /auth/profile` - Get user profile
|
||||||
- `POST /auth/sign-out` - Logout
|
- `POST /auth/sign-out` - Logout
|
||||||
- `GET /auth/callback/authentik` - OIDC callback (redirect from Authentik)
|
- `GET /auth/oauth2/callback/authentik` - OIDC callback (redirect from Authentik)
|
||||||
|
|
||||||
### Tasks (to be implemented in future issue)
|
### Tasks (to be implemented in future issue)
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ Enhance `ConnectionService` to handle OIDC-based authentication:
|
|||||||
**Integration Tests**:
|
**Integration Tests**:
|
||||||
|
|
||||||
- POST /auth/initiate starts OIDC flow with correct params
|
- POST /auth/initiate starts OIDC flow with correct params
|
||||||
- GET /auth/callback handles OIDC response and creates identity
|
- GET /auth/oauth2/callback/:providerId handles OIDC response and creates identity
|
||||||
- POST /auth/validate validates tokens from federated instances
|
- POST /auth/validate validates tokens from federated instances
|
||||||
- GET /auth/identities returns user's federated identities
|
- GET /auth/identities returns user's federated identities
|
||||||
- Federated requests with valid tokens are authenticated
|
- Federated requests with valid tokens are authenticated
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ apps/web/src/
|
|||||||
|
|
||||||
- `GET /auth/session` - Get current session
|
- `GET /auth/session` - Get current session
|
||||||
- `POST /auth/sign-out` - Logout
|
- `POST /auth/sign-out` - Logout
|
||||||
- `GET /auth/callback/authentik` - OIDC callback
|
- `GET /auth/oauth2/callback/authentik` - OIDC callback
|
||||||
|
|
||||||
### Future Endpoints (Mock Data Ready)
|
### Future Endpoints (Mock Data Ready)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user