diff --git a/modules/authentication/package.json b/modules/authentication/package.json index d5f115456..b5063d7b4 100644 --- a/modules/authentication/package.json +++ b/modules/authentication/package.json @@ -30,7 +30,7 @@ "build:bundle": "rimraf bundle && node ../../libraries/service-bundle/dist/cli.js generate-manifest && tsup && node ../../libraries/service-bundle/dist/cli.js copy-assets && node ../../libraries/service-bundle/dist/cli.js generate-lockfile", "build:docker": "docker build -t ghcr.io/conduitplatform/authentication:latest -f ./Dockerfile ../../ && docker push ghcr.io/conduitplatform/authentication:latest", "generateTypes": "sh build.sh", - "test": "tsc -p tsconfig.test.json && copyfiles -u 1 src/data/*.json dist-test/ && node --test dist-test/utils/emailRestrictions.test.js dist-test/utils/jwtSecret.test.js dist-test/utils/jwtSecretReconciler.test.js dist-test/utils/biometricAuth.test.js dist-test/utils/appleIdentityToken.test.js dist-test/utils/appleSigningKey.test.js dist-test/handlers/oauth2/utils/resolveAppleOAuthClient.test.js dist-test/handlers/oauth2/utils/validateAppleClients.test.js dist-test/config/apple.config.test.js" + "test": "tsc -p tsconfig.test.json && copyfiles -u 1 src/data/*.json dist-test/ && node --test dist-test/utils/emailRestrictions.test.js dist-test/utils/jwtSecret.test.js dist-test/utils/jwtSecretReconciler.test.js dist-test/utils/biometricAuth.test.js dist-test/utils/appleIdentityToken.test.js dist-test/utils/appleSigningKey.test.js dist-test/handlers/oauth2/utils/resolveAppleOAuthClient.test.js dist-test/handlers/oauth2/utils/validateAppleClients.test.js dist-test/handlers/oauth2/utils/oauthMode.test.js dist-test/config/apple.config.test.js" }, "license": "ISC", "directories": { diff --git a/modules/authentication/src/errors.ts b/modules/authentication/src/errors.ts index cdd0111d8..175919531 100644 --- a/modules/authentication/src/errors.ts +++ b/modules/authentication/src/errors.ts @@ -26,4 +26,10 @@ export const errors = { message: 'This email address is not allowed', description: 'The provided email address is blocked by email restrictions', }, + REGISTRATION_NOT_ALLOWED: { + conduitCode: 'REGISTRATION_NOT_ALLOWED', + grpcCode: status.PERMISSION_DENIED, + message: 'User registration is not allowed for this request', + description: 'OAuth was started in sign-in-only mode and no existing user was found. Invitation tokens still allow registration.', + }, } as const satisfies Record; diff --git a/modules/authentication/src/handlers/oauth2/OAuth2.ts b/modules/authentication/src/handlers/oauth2/OAuth2.ts index b4eec2e52..8b8a785e4 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -23,7 +23,14 @@ import { TokenProvider } from '../tokenProvider.js'; import { v4 as uuid } from 'uuid'; import { createHash } from 'crypto'; import { TeamsHandler } from '../team.js'; -import { validateStateToken } from './utils/index.js'; +import { + assertOAuthRegistrationAllowed, + OAUTH_MODE_PARAM, + redirectOnRegistrationNotAllowed, + resolveOAuthMode, + type OAuthMode, + validateStateToken, +} from './utils/index.js'; import { IAuthenticationStrategy } from '../../interfaces/index.js'; import { TokenType } from '../../constants/index.js'; import { @@ -39,6 +46,7 @@ import { } from '../../constants/index.js'; import { AuthUtils } from '../../utils/index.js'; import { assertEmailAllowed } from '../../utils/emailRestrictions.js'; +import { errors } from '../../errors.js'; export abstract class OAuth2< T, @@ -108,6 +116,7 @@ export abstract class OAuth2< expiresAt: new Date(Date.now() + 10 * 60 * 1000), customRedirectUri: call.request.params.redirectUri, anonymousUserId: anonymousUser?._id, + mode: resolveOAuthMode(call.request.params?.mode), ...this.getOAuthStateExtras(call), }, }) @@ -162,6 +171,7 @@ export abstract class OAuth2< expiresAt: new Date(Date.now() + 10 * 60 * 1000), customRedirectUri: call.request.params.redirectUri, anonymousUserId: anonymousUser?._id, + mode: resolveOAuthMode(call.request.params?.mode), ...this.getOAuthStateExtras(call), }, }) @@ -219,14 +229,20 @@ export abstract class OAuth2< }); await Token.getInstance().deleteOne(stateToken); - const user = await this.createOrUpdateUser( - payload, - stateToken.data.invitationToken, - stateToken.data.anonymousUserId, - ); const redirectUri = AuthUtils.validateRedirectUri(stateToken.data.customRedirectUri) ?? this.settings.finalRedirect; + let user: User; + try { + user = await this.createOrUpdateUser( + payload, + stateToken.data.invitationToken, + stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), + ); + } catch (err) { + return redirectOnRegistrationNotAllowed(err, redirectUri); + } return TokenProvider.getInstance().provideUserTokens( { @@ -252,6 +268,7 @@ export abstract class OAuth2< payload, call.request.params.invitationToken, call.request.context.anonymousUser?._id, + resolveOAuthMode(call.request.params?.mode), ); const config = ConfigController.getInstance().config; return TokenProvider.getInstance().provideUserTokens({ @@ -265,6 +282,7 @@ export abstract class OAuth2< payload: Payload, invitationToken?: string, anonymousUserId?: string, + mode: OAuthMode = 'both', ): Promise { let user: User | null = null; if (payload.hasOwnProperty('email') && !isNil(payload.email)) { @@ -315,6 +333,7 @@ export abstract class OAuth2< if (!user.isVerified) user.isVerified = true; user = await User.getInstance().findByIdAndUpdate(user._id, user); } else { + assertOAuthRegistrationAllowed(mode, invitationToken); if (payload.email) { assertEmailAllowed(payload.email); } @@ -374,7 +393,7 @@ export abstract class OAuth2< routingManager.route( { path: `/init/${this.providerName}`, - description: `Begins ${this.capitalizeProvider()} authentication.`, + description: `Begins ${this.capitalizeProvider()} authentication. Optional mode: "both" (default, login and register) or "signIn" (existing users only; invitation tokens still register).`, action: ConduitRouteActions.GET, queryParams: this.getInitRouteQueryParams(), middlewares: initRouteMiddleware, @@ -390,7 +409,7 @@ export abstract class OAuth2< routingManager.route( { path: `/initNative/${this.providerName}`, - description: `Begins ${this.capitalizeProvider()} native authentication.`, + description: `Begins ${this.capitalizeProvider()} native authentication. Optional mode: "both" (default, login and register) or "signIn" (existing users only; invitation tokens still register).`, action: ConduitRouteActions.GET, queryParams: this.getInitNativeRouteQueryParams(), middlewares: initRouteMiddleware, @@ -413,6 +432,7 @@ export abstract class OAuth2< state: ConduitString.Required, user: ConduitJson.Optional, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_NATIVE_COMPLETE, }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { @@ -433,6 +453,7 @@ export abstract class OAuth2< code: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_CALLBACK, }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { @@ -451,6 +472,7 @@ export abstract class OAuth2< code: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_CALLBACK, }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { @@ -468,6 +490,7 @@ export abstract class OAuth2< invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, redirectUri: ConduitString.Optional, + mode: OAUTH_MODE_PARAM, }; } @@ -476,6 +499,7 @@ export abstract class OAuth2< scopes: [ConduitString.Optional], invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, + mode: OAUTH_MODE_PARAM, }; } diff --git a/modules/authentication/src/handlers/oauth2/apple/apple.ts b/modules/authentication/src/handlers/oauth2/apple/apple.ts index c381c82b0..e794fe3f7 100644 --- a/modules/authentication/src/handlers/oauth2/apple/apple.ts +++ b/modules/authentication/src/handlers/oauth2/apple/apple.ts @@ -19,13 +19,15 @@ import axios from 'axios'; import { AppleUser } from './apple.user.js'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { TokenProvider } from '../../tokenProvider.js'; -import { Token } from '../../../models/index.js'; +import { Token, User } from '../../../models/index.js'; import { status } from '@grpc/grpc-js'; import jwksRsa from 'jwks-rsa'; import { validateStateToken, resolveAppleOAuthClient, validateAppleClients, + redirectOnRegistrationNotAllowed, + resolveOAuthMode, } from '../utils/index.js'; import { ConduitJson, @@ -37,6 +39,7 @@ import { OAUTH_CALLBACK } from '../../../constants/index.js'; import { AuthUtils } from '../../../utils/index.js'; import { verifyAppleIdentityToken } from '../../../utils/appleIdentityToken.js'; import { resolveAppleSigningKey } from '../../../utils/appleSigningKey.js'; +import { errors } from '../../../errors.js'; export class AppleHandlers extends OAuth2 { private readonly jwksClient = jwksRsa({ @@ -158,16 +161,22 @@ export class AppleHandlers extends OAuth2 { email: payload.email, data: { ...userData, ...payload.email_verified }, }; - const user = await this.createOrUpdateUser( - userParams, - stateToken.data.invitationToken, - stateToken.data.anonymousUserId, - ); - await Token.getInstance().deleteOne(stateToken); - const redirectUri = AuthUtils.validateRedirectUri(stateToken.data.customRedirectUri) ?? providerClient.redirect_uri; + let user: User; + try { + user = await this.createOrUpdateUser( + userParams, + stateToken.data.invitationToken, + stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), + ); + } catch (err) { + await Token.getInstance().deleteOne(stateToken); + return redirectOnRegistrationNotAllowed(err, redirectUri); + } + await Token.getInstance().deleteOne(stateToken); const conduitClientId = stateToken.data.clientId; return TokenProvider.getInstance()!.provideUserTokens( @@ -234,6 +243,7 @@ export class AppleHandlers extends OAuth2 { userParams, stateToken.data.invitationToken, stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), ); await Token.getInstance().deleteOne(stateToken); @@ -259,6 +269,7 @@ export class AppleHandlers extends OAuth2 { state: ConduitString.Required, user: ConduitJson.Optional, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_CALLBACK, }, new ConduitRouteReturnDefinition(`AppleResponse`, { diff --git a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts index cadbb228a..64e3381bf 100644 --- a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts +++ b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts @@ -18,6 +18,8 @@ import { } from '../interfaces/index.js'; import { OAuth2 } from '../OAuth2.js'; import { FacebookUser } from './facebook.user.js'; +import { OAUTH_MODE_PARAM } from '../utils/index.js'; +import { errors } from '../../../errors.js'; // todo migrate to use native method properly export class FacebookHandlers extends OAuth2 { @@ -80,14 +82,16 @@ export class FacebookHandlers extends OAuth2 { { path: '/facebook', action: ConduitRouteActions.POST, - description: `Login/register with Facebook by providing a token from the client.`, + description: `Login/register with Facebook by providing a token from the client. Optional mode: "both" (default, login and register) or "signIn" (existing users only; invitation tokens still register).`, bodyParams: { access_token: ConduitString.Required, invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, scopes: [ConduitString.Optional], + mode: OAUTH_MODE_PARAM, }, middlewares: ['authMiddleware?', 'checkAnonymousMiddleware'], + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_NATIVE_COMPLETE, }, new ConduitRouteReturnDefinition('FacebookResponse', { diff --git a/modules/authentication/src/handlers/oauth2/google/google.ts b/modules/authentication/src/handlers/oauth2/google/google.ts index 52b6886a6..75a527bc9 100644 --- a/modules/authentication/src/handlers/oauth2/google/google.ts +++ b/modules/authentication/src/handlers/oauth2/google/google.ts @@ -17,6 +17,8 @@ import { Payload, ProviderConfig, } from '../interfaces/index.js'; +import { OAUTH_MODE_PARAM } from '../utils/index.js'; +import { errors } from '../../../errors.js'; // todo migrate to use native method properly export class GoogleHandlers extends OAuth2 { @@ -62,7 +64,7 @@ export class GoogleHandlers extends OAuth2 { { path: '/google', action: ConduitRouteActions.POST, - description: `Login/register with Google by providing a token from the client.`, + description: `Login/register with Google by providing a token from the client. Optional mode: "both" (default, login and register) or "signIn" (existing users only; invitation tokens still register).`, bodyParams: { id_token: ConduitString.Required, access_token: ConduitString.Required, @@ -70,8 +72,10 @@ export class GoogleHandlers extends OAuth2 { invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, scopes: [ConduitString.Optional], + mode: OAUTH_MODE_PARAM, }, middlewares: ['authMiddleware?', 'checkAnonymousMiddleware'], + errors: [errors.REGISTRATION_NOT_ALLOWED], rateLimit: OAUTH_NATIVE_COMPLETE, }, new ConduitRouteReturnDefinition('GoogleResponse', { diff --git a/modules/authentication/src/handlers/oauth2/utils/index.ts b/modules/authentication/src/handlers/oauth2/utils/index.ts index 3f1c68ced..b542f6abe 100644 --- a/modules/authentication/src/handlers/oauth2/utils/index.ts +++ b/modules/authentication/src/handlers/oauth2/utils/index.ts @@ -2,3 +2,4 @@ export * from './ValidateStateToken.js'; export * from './MakeRequest.js'; export * from './resolveAppleOAuthClient.js'; export * from './validateAppleClients.js'; +export * from './oauthMode.js'; diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts new file mode 100644 index 000000000..ab9e821d7 --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts @@ -0,0 +1,130 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { GrpcError } from '@conduitplatform/grpc-sdk'; +import { ModuleError } from '@conduitplatform/module-tools'; +import { status } from '@grpc/grpc-js'; +import { errors } from '../../../errors.js'; +import { + assertOAuthRegistrationAllowed, + isRegistrationNotAllowedError, + redirectOnRegistrationNotAllowed, + resolveOAuthMode, +} from './oauthMode.js'; + +describe('resolveOAuthMode', () => { + it('defaults to both when the value is omitted', () => { + assert.equal(resolveOAuthMode(undefined), 'both'); + assert.equal(resolveOAuthMode(null), 'both'); + assert.equal(resolveOAuthMode(''), 'both'); + }); + + it('returns both when both is provided', () => { + assert.equal(resolveOAuthMode('both'), 'both'); + }); + + it('returns signIn when signIn is provided', () => { + assert.equal(resolveOAuthMode('signIn'), 'signIn'); + }); + + it('throws INVALID_ARGUMENT for an invalid value', () => { + assert.throws( + () => resolveOAuthMode('register'), + (err: unknown) => + err instanceof GrpcError && + err.code === status.INVALID_ARGUMENT && + err.message.includes('mode must be "signIn" or "both"'), + ); + }); +}); + +describe('assertOAuthRegistrationAllowed', () => { + it('allows registration when mode is both', () => { + assert.doesNotThrow(() => assertOAuthRegistrationAllowed('both')); + }); + + it('throws REGISTRATION_NOT_ALLOWED when mode is signIn', () => { + assert.throws( + () => assertOAuthRegistrationAllowed('signIn'), + (err: unknown) => { + if (!(err instanceof ModuleError) || err.code !== status.PERMISSION_DENIED) { + return false; + } + const parsed = JSON.parse(err.message) as { + conduitCode: string; + message: string; + }; + return ( + parsed.conduitCode === errors.REGISTRATION_NOT_ALLOWED.conduitCode && + parsed.message === errors.REGISTRATION_NOT_ALLOWED.message + ); + }, + ); + }); + + it('allows signIn registration when an invitation token is present', () => { + assert.doesNotThrow(() => assertOAuthRegistrationAllowed('signIn', 'invite-token')); + }); + + it('still blocks signIn when the invitation token is empty or whitespace', () => { + assert.throws( + () => assertOAuthRegistrationAllowed('signIn', ''), + (err: unknown) => err instanceof ModuleError, + ); + assert.throws( + () => assertOAuthRegistrationAllowed('signIn', ' '), + (err: unknown) => err instanceof ModuleError, + ); + }); +}); + +describe('redirectOnRegistrationNotAllowed', () => { + it('appends conduitCode on the app redirect for REGISTRATION_NOT_ALLOWED', () => { + const err = new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + assert.deepEqual(redirectOnRegistrationNotAllowed(err, 'https://app.example/oauth'), { + redirect: 'https://app.example/oauth?conduitCode=REGISTRATION_NOT_ALLOWED', + }); + }); + + it('preserves existing query params on the redirect', () => { + const err = new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + assert.deepEqual( + redirectOnRegistrationNotAllowed(err, 'https://app.example/oauth?from=login'), + { + redirect: + 'https://app.example/oauth?from=login&conduitCode=REGISTRATION_NOT_ALLOWED', + }, + ); + }); + + it('rethrows when the error is not REGISTRATION_NOT_ALLOWED', () => { + const err = new GrpcError(status.NOT_FOUND, 'missing'); + assert.throws( + () => redirectOnRegistrationNotAllowed(err, 'https://app.example/oauth'), + (thrown: unknown) => thrown === err, + ); + }); + + it('rethrows when there is no redirect URI', () => { + const err = new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + assert.throws( + () => redirectOnRegistrationNotAllowed(err), + (thrown: unknown) => thrown === err, + ); + }); + + it('rethrows when the redirect URI is not a valid absolute URL', () => { + const err = new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + assert.throws( + () => redirectOnRegistrationNotAllowed(err, '/relative'), + (thrown: unknown) => thrown === err, + ); + }); + + it('identifies ModuleError REGISTRATION_NOT_ALLOWED payloads', () => { + assert.equal( + isRegistrationNotAllowedError(new ModuleError(errors.REGISTRATION_NOT_ALLOWED)), + true, + ); + assert.equal(isRegistrationNotAllowedError(new Error('nope')), false); + }); +}); diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts new file mode 100644 index 000000000..74ab2b05b --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -0,0 +1,80 @@ +import { GrpcError } from '@conduitplatform/grpc-sdk'; +import { ConduitString, ModuleError } from '@conduitplatform/module-tools'; +import { status } from '@grpc/grpc-js'; +import { errors } from '../../../errors.js'; + +export const OAUTH_MODES = ['signIn', 'both'] as const; +export type OAuthMode = (typeof OAUTH_MODES)[number]; + +export const OAUTH_MODE_PARAM = ConduitString.OptionalWith({ + pattern: '^(signIn|both)$', + message: 'mode must be "signIn" or "both"', +}); + +export function isOAuthMode(value: unknown): value is OAuthMode { + return value === 'signIn' || value === 'both'; +} + +export function resolveOAuthMode(value: unknown): OAuthMode { + if (value === undefined || value === null || value === '') { + return 'both'; + } + if (isOAuthMode(value)) { + return value; + } + throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); +} + +export function isOAuthInvitationToken(invitationToken?: string): boolean { + return typeof invitationToken === 'string' && invitationToken.trim().length > 0; +} + +export function assertOAuthRegistrationAllowed( + mode: OAuthMode, + invitationToken?: string, +): void { + switch (mode) { + case 'both': + return; + case 'signIn': + if (isOAuthInvitationToken(invitationToken)) { + return; + } + throw new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + default: { + const _exhaustive: never = mode; + throw new GrpcError(status.INTERNAL, `Unhandled OAuth mode: ${_exhaustive}`); + } + } +} + +export function isRegistrationNotAllowedError(err: unknown): boolean { + if (!(err instanceof ModuleError)) { + return false; + } + try { + const parsed = JSON.parse(err.message) as { conduitCode?: string }; + return parsed.conduitCode === errors.REGISTRATION_NOT_ALLOWED.conduitCode; + } catch { + return false; + } +} + +export function redirectOnRegistrationNotAllowed( + err: unknown, + redirectUri?: string, +): { redirect: string } { + if (!isRegistrationNotAllowedError(err) || !redirectUri) { + throw err; + } + try { + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set( + 'conduitCode', + errors.REGISTRATION_NOT_ALLOWED.conduitCode, + ); + return { redirect: redirectUrl.toString() }; + } catch { + throw err; + } +} diff --git a/modules/authentication/tsconfig.test.json b/modules/authentication/tsconfig.test.json index abbff0366..a1d261ea2 100644 --- a/modules/authentication/tsconfig.test.json +++ b/modules/authentication/tsconfig.test.json @@ -23,6 +23,8 @@ "src/handlers/oauth2/utils/resolveAppleOAuthClient.test.ts", "src/handlers/oauth2/utils/validateAppleClients.ts", "src/handlers/oauth2/utils/validateAppleClients.test.ts", + "src/handlers/oauth2/utils/oauthMode.ts", + "src/handlers/oauth2/utils/oauthMode.test.ts", "src/handlers/oauth2/interfaces/AppleProviderConfig.ts", "src/handlers/oauth2/interfaces/ProviderConfig.ts", "src/config/apple.config.ts",