Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ APP_URL=http://localhost:3000
API_BASE_URL=http://localhost:4000
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=change-me-in-production-min32chars
AUTH_SECRET=change-me-in-production-min32chars
POSTGRES_DB=xpenser
POSTGRES_USER=xpenser
POSTGRES_PASSWORD=xpenser_secret
Expand All @@ -14,9 +15,12 @@ DB_PASSWORD=xpenser_secret
JWT_SECRET=change-me-in-production-min32chars
JWT_EXPIRES_IN=1209600
WEB_API_SERVICE_SECRET=change-me-in-production-min32chars
PASSPORT_BASE_URL=https://auth.cleverbrush.com
PASSPORT_PROJECT=xpenser
PASSPORT_ENVIRONMENT=production
GOOGLE_SIGN_IN_MODE=auto
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
PASSPORT_BASE_URL=
PASSPORT_PROJECT=
PASSPORT_ENVIRONMENT=
PASSPORT_PUBLIC_KEY=
FRANKFURTER_BASE_URL=https://api.frankfurter.dev/v2
BRANDFETCH_API_KEY=
Expand Down
1 change: 1 addition & 0 deletions PR_ENVIRONMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ PR_ENV_STATE_DIR=/var/lib/pr-envs
PR_ENV_PORT_BASE=3000
PROD_COMPOSE_PROJECT=xpenser
GIT_REPOSITORY_URL=git@github.com:cleverbrush/xpenser.git
GOOGLE_SIGN_IN_MODE=passport
PASSPORT_BASE_URL=https://auth.cleverbrush.com
PASSPORT_PROJECT=xpenser
POSTGRES_DB=xpenser
Expand Down
82 changes: 76 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,77 @@ Local URLs:
- API health check: http://localhost:4000/health
- OpenAPI JSON: http://localhost:4000/openapi.json

### Google sign-in through Passport
### Authentication

Google sign-in is brokered by Passport at `auth.cleverbrush.com`. The web app
redirects users to Passport, Passport calls the xpenser API to resolve or
auto-create the local user, and the callback exchanges the Passport code for the
same xpenser API JWT used by email/password login.
Email/password sign-in is built in and works without any external auth provider.
Accounts created this way must confirm their email before signing in.

Configure the xpenser services with:
Google sign-in has two supported modes:

- Direct Google OAuth for self-hosted deployments.
- Cleverbrush Passport for the hosted Cleverbrush deployment.

Select the mode with `GOOGLE_SIGN_IN_MODE`:

```env
GOOGLE_SIGN_IN_MODE=auto
```

`auto` uses direct Google OAuth when `AUTH_GOOGLE_ID` and
`AUTH_GOOGLE_SECRET` are configured. If those are not set, it uses Passport only
when all Passport variables are configured. If neither auth provider is
configured, the Google sign-in button is hidden and email/password sign-in still
works.

Use `GOOGLE_SIGN_IN_MODE=direct` to require direct Google OAuth,
`GOOGLE_SIGN_IN_MODE=passport` to require Passport, or
`GOOGLE_SIGN_IN_MODE=disabled` to hide Google sign-in even when credentials are
present.

#### Direct Google OAuth for self-hosting

Create an OAuth 2.0 client in Google Cloud Console:

- Application type: Web application
- Authorized JavaScript origin: your public `APP_URL`
- Authorized redirect URI: `${APP_URL}/api/auth/callback/google`

For local development with the default `APP_URL`, use:

```text
http://localhost:3000/api/auth/callback/google
```

Configure the web app with Auth.js-standard Google variables:

```env
APP_URL=https://xpenser.example.com
NEXTAUTH_URL=https://xpenser.example.com
NEXTAUTH_SECRET=replace-with-at-least-32-characters
AUTH_SECRET=replace-with-the-same-value-as-NEXTAUTH_SECRET
GOOGLE_SIGN_IN_MODE=auto
AUTH_GOOGLE_ID=your-google-oauth-client-id
AUTH_GOOGLE_SECRET=your-google-oauth-client-secret
```

The web app validates the Google profile through Auth.js, then calls the private
xpenser API with `WEB_API_SERVICE_SECRET`. The API resolves or creates a local
`google` user, stores the Google subject in `external_identities`, and returns
the same xpenser API JWT used by email/password sessions.

Google accounts must have a verified email address. If a local email/password
account already exists with the same email, Google sign-in is rejected instead of
silently linking the accounts.

#### Passport for Cleverbrush deployment

Passport is a private Cleverbrush auth broker. Self-hosted deployments should
use direct Google OAuth unless they run their own compatible Passport service.

Configure both services with:

```env
GOOGLE_SIGN_IN_MODE=passport
PASSPORT_BASE_URL=https://auth.cleverbrush.com
PASSPORT_PROJECT=xpenser
PASSPORT_ENVIRONMENT=production
Expand Down Expand Up @@ -263,3 +324,12 @@ change the relevant app port before starting the dev servers.

If login/register fails after changing secrets or resetting data, stop the dev
server, clear browser cookies for `localhost`, and start the app again.

If the Google sign-in button is hidden, either set `AUTH_GOOGLE_ID` and
`AUTH_GOOGLE_SECRET` for direct Google OAuth, set complete Passport variables
with `GOOGLE_SIGN_IN_MODE=passport`, or set `GOOGLE_SIGN_IN_MODE=direct` to fail
fast when Google credentials are missing.

If Google returns a redirect URI mismatch, add the exact
`${APP_URL}/api/auth/callback/google` URL to the Google OAuth client. The scheme,
host, port, and path must match the public URL users open in the browser.
10 changes: 10 additions & 0 deletions apps/api/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ export const PassportExchangeEndpoint = api.auth.passportExchange
.tags('auth')
.operationId('passportExchange');

export const GoogleSignInEndpoint = api.auth.googleSignIn
.inject({ db: DbToken, config: ConfigToken })
.summary('Direct Google sign-in')
.description(
'Maps an Auth.js Google identity to a local xpenser user and issues an API JWT.'
)
.tags('auth')
.operationId('googleSignIn');

export const SessionTokenEndpoint = api.auth.sessionToken
.inject({ db: DbToken, config: ConfigToken })
.summary('Web session token')
Expand Down Expand Up @@ -393,6 +402,7 @@ export const endpoints = {
resendEmailConfirmation: ResendEmailConfirmationEndpoint,
passportResolveUser: PassportResolveUserEndpoint,
passportExchange: PassportExchangeEndpoint,
googleSignIn: GoogleSignInEndpoint,
sessionToken: SessionTokenEndpoint,
me: GetMeEndpoint
},
Expand Down
46 changes: 45 additions & 1 deletion apps/api/src/api/handlers/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import type { Config } from '../../config.js';
import type { AppDb } from '../../db/schemas.js';
import { hashPassword } from '../../security/password.js';
import { loginHandler, sessionTokenHandler } from './auth.js';
import {
googleSignInHandler,
loginHandler,
sessionTokenHandler
} from './auth.js';

const secret = 'web-service-secret-minimum-32-chars';
const config = {
Expand Down Expand Up @@ -97,6 +101,46 @@ describe('session token handler', () => {
});
});

describe('direct Google sign-in handler', () => {
const body = {
providerSubject: 'google-subject',
email: 'jane@example.com',
emailVerified: true
};

it('rejects missing web service credentials', async () => {
const result = await googleSignInHandler(
{
body,
context: { headers: {} }
} as never,
{ db: mockDb(undefined), config } as never
);

expect(result).toMatchObject({
status: 401,
body: { message: 'Invalid web service credentials.' }
});
});

it('rejects invalid web service credentials', async () => {
const result = await googleSignInHandler(
{
body,
context: {
headers: { 'x-xpenser-web-secret': `${secret}-wrong` }
}
} as never,
{ db: mockDb(undefined), config } as never
);

expect(result).toMatchObject({
status: 401,
body: { message: 'Invalid web service credentials.' }
});
});
});

describe('login handler', () => {
it('rejects valid credentials until local email is confirmed', async () => {
const passwordHash = await hashPassword('correct horse battery staple');
Expand Down
33 changes: 32 additions & 1 deletion apps/api/src/api/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
getUserPreference,
InvalidCredentialsError,
InvalidEmailConfirmationTokenError,
InvalidGoogleIdentityError,
InvalidPassportIdentityError,
issueGoogleUserToken,
issuePassportUserToken,
issueUserToken,
loginUser,
Expand All @@ -26,6 +28,7 @@ import {
import type {
ConfirmEmailEndpoint,
GetMeEndpoint,
GoogleSignInEndpoint,
LoginEndpoint,
PassportExchangeEndpoint,
PassportResolveUserEndpoint,
Expand Down Expand Up @@ -104,7 +107,10 @@ export const passportResolveUserHandler: Handler<
);
return await resolvePassportGoogleUser(db, body);
} catch (err) {
if (err instanceof InvalidPassportIdentityError) {
if (
err instanceof InvalidGoogleIdentityError ||
err instanceof InvalidPassportIdentityError
) {
return ActionResult.badRequest({ message: err.message });
}
if (err instanceof PassportAuthError) {
Expand Down Expand Up @@ -142,6 +148,31 @@ export const passportExchangeHandler: Handler<
}
};

export const googleSignInHandler: Handler<typeof GoogleSignInEndpoint> = async (
{ body, context },
{ db, config }
) => {
if (
!verifyWebApiServiceSecret(
config,
context.headers[webServiceSecretHeader]
)
) {
return ActionResult.unauthorized({
message: 'Invalid web service credentials.'
});
}

try {
return await issueGoogleUserToken(db, config, body);
} catch (err) {
if (err instanceof InvalidGoogleIdentityError) {
return ActionResult.badRequest({ message: err.message });
}
throw err;
}
};

export const sessionTokenHandler: Handler<typeof SessionTokenEndpoint> = async (
{ body, context },
{ db, config }
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
confirmEmailHandler,
getMeHandler,
googleSignInHandler,
loginHandler,
passportExchangeHandler,
passportResolveUserHandler,
Expand Down Expand Up @@ -65,6 +66,7 @@ export const handlers = {
resendEmailConfirmation: resendEmailConfirmationHandler,
passportResolveUser: passportResolveUserHandler,
passportExchange: passportExchangeHandler,
googleSignIn: googleSignInHandler,
sessionToken: sessionTokenHandler,
me: getMeHandler
},
Expand Down
Loading
Loading