From 1021db03babcd22232e6f66b9510cea5183213d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 09:37:26 +0000 Subject: [PATCH 1/4] feat(authentication): add OAuth sign-in-only mode Allow OAuth init and token routes to request signIn or both (default). Sign-in-only blocks new user creation so clients can collect terms and profile data before registration. Co-authored-by: Konstantinos Kopanidis --- modules/authentication/package.json | 2 +- modules/authentication/src/errors.ts | 6 ++ .../src/handlers/oauth2/OAuth2.ts | 24 +++++++- .../src/handlers/oauth2/apple/apple.ts | 5 ++ .../src/handlers/oauth2/facebook/facebook.ts | 6 +- .../src/handlers/oauth2/google/google.ts | 6 +- .../src/handlers/oauth2/utils/index.ts | 1 + .../handlers/oauth2/utils/oauthMode.test.ts | 58 +++++++++++++++++++ .../src/handlers/oauth2/utils/oauthMode.ts | 39 +++++++++++++ modules/authentication/tsconfig.test.json | 2 + 10 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts create mode 100644 modules/authentication/src/handlers/oauth2/utils/oauthMode.ts diff --git a/modules/authentication/package.json b/modules/authentication/package.json index 0dcbeef20..d8eb0b90e 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..2939efe84 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', + }, } as const satisfies Record; diff --git a/modules/authentication/src/handlers/oauth2/OAuth2.ts b/modules/authentication/src/handlers/oauth2/OAuth2.ts index b4eec2e52..5b9465542 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -23,7 +23,13 @@ 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, + resolveOAuthMode, + type OAuthMode, + validateStateToken, +} from './utils/index.js'; import { IAuthenticationStrategy } from '../../interfaces/index.js'; import { TokenType } from '../../constants/index.js'; import { @@ -39,6 +45,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 +115,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 +170,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), }, }) @@ -223,6 +232,7 @@ export abstract class OAuth2< payload, stateToken.data.invitationToken, stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), ); const redirectUri = AuthUtils.validateRedirectUri(stateToken.data.customRedirectUri) ?? @@ -252,6 +262,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 +276,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 +327,7 @@ export abstract class OAuth2< if (!user.isVerified) user.isVerified = true; user = await User.getInstance().findByIdAndUpdate(user._id, user); } else { + assertOAuthRegistrationAllowed(mode); if (payload.email) { assertEmailAllowed(payload.email); } @@ -374,7 +387,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).`, action: ConduitRouteActions.GET, queryParams: this.getInitRouteQueryParams(), middlewares: initRouteMiddleware, @@ -390,7 +403,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).`, action: ConduitRouteActions.GET, queryParams: this.getInitNativeRouteQueryParams(), middlewares: initRouteMiddleware, @@ -413,6 +426,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 +447,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 +466,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 +484,7 @@ export abstract class OAuth2< invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, redirectUri: ConduitString.Optional, + mode: OAUTH_MODE_PARAM, }; } @@ -476,6 +493,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..54d47d9aa 100644 --- a/modules/authentication/src/handlers/oauth2/apple/apple.ts +++ b/modules/authentication/src/handlers/oauth2/apple/apple.ts @@ -26,6 +26,7 @@ import { validateStateToken, resolveAppleOAuthClient, validateAppleClients, + resolveOAuthMode, } from '../utils/index.js'; import { ConduitJson, @@ -37,6 +38,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({ @@ -162,6 +164,7 @@ export class AppleHandlers extends OAuth2 { userParams, stateToken.data.invitationToken, stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), ); await Token.getInstance().deleteOne(stateToken); @@ -234,6 +237,7 @@ export class AppleHandlers extends OAuth2 { userParams, stateToken.data.invitationToken, stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), ); await Token.getInstance().deleteOne(stateToken); @@ -259,6 +263,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..6f04a5710 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).`, 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..721a4ae1e 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).`, 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..1cc67bdb8 --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts @@ -0,0 +1,58 @@ +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, 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 + ); + }, + ); + }); +}); 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..220460f8e --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -0,0 +1,39 @@ +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 assertOAuthRegistrationAllowed(mode: OAuthMode): void { + switch (mode) { + case 'both': + return; + case 'signIn': + throw new ModuleError(errors.REGISTRATION_NOT_ALLOWED); + default: { + const _exhaustive: never = mode; + throw new GrpcError(status.INTERNAL, `Unhandled OAuth mode: ${_exhaustive}`); + } + } +} 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", From 1041f7b0653eed204b24188cd0f754f0b4d8a5bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 10:48:02 +0000 Subject: [PATCH 2/4] feat(authentication): cap OAuth mode with config Request mode remains a per-call UX switch. Provider allowRegistration (default true) is the policy ceiling so clients cannot force account creation when registration is disabled. --- .../src/constants/Oauth2.default.schema.ts | 7 ++++++ modules/authentication/src/errors.ts | 3 ++- .../src/handlers/oauth2/OAuth2.ts | 4 ++++ .../oauth2/interfaces/ProviderConfig.ts | 1 + .../handlers/oauth2/utils/oauthMode.test.ts | 13 ++++++++++ .../src/handlers/oauth2/utils/oauthMode.ts | 24 +++++++++---------- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/modules/authentication/src/constants/Oauth2.default.schema.ts b/modules/authentication/src/constants/Oauth2.default.schema.ts index 484cb5c45..22677ef7d 100644 --- a/modules/authentication/src/constants/Oauth2.default.schema.ts +++ b/modules/authentication/src/constants/Oauth2.default.schema.ts @@ -25,4 +25,11 @@ export const oauth2Schema = { format: 'Boolean', default: true, }, + allowRegistration: { + doc: + 'When disabled, this provider can only sign in existing users. ' + + 'Request mode=both cannot override this.', + format: 'Boolean', + default: true, + }, }; diff --git a/modules/authentication/src/errors.ts b/modules/authentication/src/errors.ts index 2939efe84..44e342a66 100644 --- a/modules/authentication/src/errors.ts +++ b/modules/authentication/src/errors.ts @@ -30,6 +30,7 @@ export const errors = { 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', + description: + 'OAuth registration is disabled for this request (sign-in-only mode or provider allowRegistration is false)', }, } as const satisfies Record; diff --git a/modules/authentication/src/handlers/oauth2/OAuth2.ts b/modules/authentication/src/handlers/oauth2/OAuth2.ts index 5b9465542..96ac8d26c 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -278,6 +278,10 @@ export abstract class OAuth2< anonymousUserId?: string, mode: OAuthMode = 'both', ): Promise { + const allowRegistration = + ConfigController.getInstance().config[this.providerName]?.allowRegistration !== + false; + mode = resolveOAuthMode(mode, allowRegistration); let user: User | null = null; if (payload.hasOwnProperty('email') && !isNil(payload.email)) { user = await User.getInstance().findOne({ diff --git a/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts b/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts index cfb2323bf..8e450a3a2 100644 --- a/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts +++ b/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts @@ -1,5 +1,6 @@ export interface ProviderConfig { accountLinking: boolean; + allowRegistration?: boolean; clientId: string; clientSecret?: string; redirect_uri: string; diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts index 1cc67bdb8..a3c5bd7a3 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts @@ -30,6 +30,19 @@ describe('resolveOAuthMode', () => { err.message.includes('mode must be "signIn" or "both"'), ); }); + + it('caps to signIn when provider registration is disabled', () => { + assert.equal(resolveOAuthMode(undefined, false), 'signIn'); + assert.equal(resolveOAuthMode('both', false), 'signIn'); + assert.equal(resolveOAuthMode('signIn', false), 'signIn'); + }); + + it('still rejects invalid values when registration is disabled', () => { + assert.throws( + () => resolveOAuthMode('register', false), + (err: unknown) => err instanceof GrpcError && err.code === status.INVALID_ARGUMENT, + ); + }); }); describe('assertOAuthRegistrationAllowed', () => { diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts index 220460f8e..eb67a8c81 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -3,26 +3,26 @@ 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 type OAuthMode = 'signIn' | 'both'; 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 { +export function resolveOAuthMode( + value: unknown, + allowRegistration: boolean = true, +): OAuthMode { + let requested: OAuthMode; if (value === undefined || value === null || value === '') { - return 'both'; - } - if (isOAuthMode(value)) { - return value; + requested = 'both'; + } else if (value === 'signIn' || value === 'both') { + requested = value; + } else { + throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); } - throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); + return allowRegistration ? requested : 'signIn'; } export function assertOAuthRegistrationAllowed(mode: OAuthMode): void { From 30e53ffd617589e7591f5184d19ebaa82bed729c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 10:49:57 +0000 Subject: [PATCH 3/4] Revert "feat(authentication): cap OAuth mode with config" This reverts commit b94fe9a25aad52640b37731da230dc7998452a35. --- .../src/constants/Oauth2.default.schema.ts | 7 ------ modules/authentication/src/errors.ts | 3 +-- .../src/handlers/oauth2/OAuth2.ts | 4 ---- .../oauth2/interfaces/ProviderConfig.ts | 1 - .../handlers/oauth2/utils/oauthMode.test.ts | 13 ---------- .../src/handlers/oauth2/utils/oauthMode.ts | 24 +++++++++---------- 6 files changed, 13 insertions(+), 39 deletions(-) diff --git a/modules/authentication/src/constants/Oauth2.default.schema.ts b/modules/authentication/src/constants/Oauth2.default.schema.ts index 22677ef7d..484cb5c45 100644 --- a/modules/authentication/src/constants/Oauth2.default.schema.ts +++ b/modules/authentication/src/constants/Oauth2.default.schema.ts @@ -25,11 +25,4 @@ export const oauth2Schema = { format: 'Boolean', default: true, }, - allowRegistration: { - doc: - 'When disabled, this provider can only sign in existing users. ' + - 'Request mode=both cannot override this.', - format: 'Boolean', - default: true, - }, }; diff --git a/modules/authentication/src/errors.ts b/modules/authentication/src/errors.ts index 44e342a66..2939efe84 100644 --- a/modules/authentication/src/errors.ts +++ b/modules/authentication/src/errors.ts @@ -30,7 +30,6 @@ export const errors = { conduitCode: 'REGISTRATION_NOT_ALLOWED', grpcCode: status.PERMISSION_DENIED, message: 'User registration is not allowed for this request', - description: - 'OAuth registration is disabled for this request (sign-in-only mode or provider allowRegistration is false)', + description: 'OAuth was started in sign-in-only mode and no existing user was found', }, } as const satisfies Record; diff --git a/modules/authentication/src/handlers/oauth2/OAuth2.ts b/modules/authentication/src/handlers/oauth2/OAuth2.ts index 96ac8d26c..5b9465542 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -278,10 +278,6 @@ export abstract class OAuth2< anonymousUserId?: string, mode: OAuthMode = 'both', ): Promise { - const allowRegistration = - ConfigController.getInstance().config[this.providerName]?.allowRegistration !== - false; - mode = resolveOAuthMode(mode, allowRegistration); let user: User | null = null; if (payload.hasOwnProperty('email') && !isNil(payload.email)) { user = await User.getInstance().findOne({ diff --git a/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts b/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts index 8e450a3a2..cfb2323bf 100644 --- a/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts +++ b/modules/authentication/src/handlers/oauth2/interfaces/ProviderConfig.ts @@ -1,6 +1,5 @@ export interface ProviderConfig { accountLinking: boolean; - allowRegistration?: boolean; clientId: string; clientSecret?: string; redirect_uri: string; diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts index a3c5bd7a3..1cc67bdb8 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts @@ -30,19 +30,6 @@ describe('resolveOAuthMode', () => { err.message.includes('mode must be "signIn" or "both"'), ); }); - - it('caps to signIn when provider registration is disabled', () => { - assert.equal(resolveOAuthMode(undefined, false), 'signIn'); - assert.equal(resolveOAuthMode('both', false), 'signIn'); - assert.equal(resolveOAuthMode('signIn', false), 'signIn'); - }); - - it('still rejects invalid values when registration is disabled', () => { - assert.throws( - () => resolveOAuthMode('register', false), - (err: unknown) => err instanceof GrpcError && err.code === status.INVALID_ARGUMENT, - ); - }); }); describe('assertOAuthRegistrationAllowed', () => { diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts index eb67a8c81..220460f8e 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -3,26 +3,26 @@ import { ConduitString, ModuleError } from '@conduitplatform/module-tools'; import { status } from '@grpc/grpc-js'; import { errors } from '../../../errors.js'; -export type OAuthMode = 'signIn' | 'both'; +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 resolveOAuthMode( - value: unknown, - allowRegistration: boolean = true, -): OAuthMode { - let requested: OAuthMode; +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 === '') { - requested = 'both'; - } else if (value === 'signIn' || value === 'both') { - requested = value; - } else { - throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); + return 'both'; + } + if (isOAuthMode(value)) { + return value; } - return allowRegistration ? requested : 'signIn'; + throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); } export function assertOAuthRegistrationAllowed(mode: OAuthMode): void { From e76b28985051a90b6aef4dddcb113971127f2e43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 09:12:08 +0000 Subject: [PATCH 4/4] fix(authentication): honor OAuth invites and redirect signIn denials A present invitation token is registration intent, so mode=signIn no longer blocks invite signup. Web OAuth hooks send the browser back to the app with conduitCode=REGISTRATION_NOT_ALLOWED instead of 403 JSON. --- modules/authentication/src/errors.ts | 2 +- .../src/handlers/oauth2/OAuth2.ts | 24 +++--- .../src/handlers/oauth2/apple/apple.ts | 24 +++--- .../src/handlers/oauth2/facebook/facebook.ts | 2 +- .../src/handlers/oauth2/google/google.ts | 2 +- .../handlers/oauth2/utils/oauthMode.test.ts | 74 ++++++++++++++++++- .../src/handlers/oauth2/utils/oauthMode.ts | 43 ++++++++++- 7 files changed, 148 insertions(+), 23 deletions(-) diff --git a/modules/authentication/src/errors.ts b/modules/authentication/src/errors.ts index 2939efe84..175919531 100644 --- a/modules/authentication/src/errors.ts +++ b/modules/authentication/src/errors.ts @@ -30,6 +30,6 @@ export const errors = { 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', + 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 5b9465542..8b8a785e4 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -26,6 +26,7 @@ import { TeamsHandler } from '../team.js'; import { assertOAuthRegistrationAllowed, OAUTH_MODE_PARAM, + redirectOnRegistrationNotAllowed, resolveOAuthMode, type OAuthMode, validateStateToken, @@ -228,15 +229,20 @@ export abstract class OAuth2< }); await Token.getInstance().deleteOne(stateToken); - const user = await this.createOrUpdateUser( - payload, - stateToken.data.invitationToken, - stateToken.data.anonymousUserId, - resolveOAuthMode(stateToken.data.mode), - ); 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( { @@ -327,7 +333,7 @@ export abstract class OAuth2< if (!user.isVerified) user.isVerified = true; user = await User.getInstance().findByIdAndUpdate(user._id, user); } else { - assertOAuthRegistrationAllowed(mode); + assertOAuthRegistrationAllowed(mode, invitationToken); if (payload.email) { assertEmailAllowed(payload.email); } @@ -387,7 +393,7 @@ export abstract class OAuth2< routingManager.route( { path: `/init/${this.providerName}`, - description: `Begins ${this.capitalizeProvider()} authentication. Optional mode: "both" (default, login and register) or "signIn" (existing users only).`, + 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, @@ -403,7 +409,7 @@ export abstract class OAuth2< routingManager.route( { path: `/initNative/${this.providerName}`, - description: `Begins ${this.capitalizeProvider()} native authentication. Optional mode: "both" (default, login and register) or "signIn" (existing users only).`, + 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, diff --git a/modules/authentication/src/handlers/oauth2/apple/apple.ts b/modules/authentication/src/handlers/oauth2/apple/apple.ts index 54d47d9aa..e794fe3f7 100644 --- a/modules/authentication/src/handlers/oauth2/apple/apple.ts +++ b/modules/authentication/src/handlers/oauth2/apple/apple.ts @@ -19,13 +19,14 @@ 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 { @@ -160,17 +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, - resolveOAuthMode(stateToken.data.mode), - ); - 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( diff --git a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts index 6f04a5710..64e3381bf 100644 --- a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts +++ b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts @@ -82,7 +82,7 @@ export class FacebookHandlers extends OAuth2 { { path: '/facebook', action: ConduitRouteActions.POST, - description: `Login/register with Facebook by providing a token from the client. Optional mode: "both" (default, login and register) or "signIn" (existing users only).`, + 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, diff --git a/modules/authentication/src/handlers/oauth2/google/google.ts b/modules/authentication/src/handlers/oauth2/google/google.ts index 721a4ae1e..75a527bc9 100644 --- a/modules/authentication/src/handlers/oauth2/google/google.ts +++ b/modules/authentication/src/handlers/oauth2/google/google.ts @@ -64,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. Optional mode: "both" (default, login and register) or "signIn" (existing users only).`, + 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, diff --git a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts index 1cc67bdb8..ab9e821d7 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.test.ts @@ -4,7 +4,12 @@ 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, resolveOAuthMode } from './oauthMode.js'; +import { + assertOAuthRegistrationAllowed, + isRegistrationNotAllowedError, + redirectOnRegistrationNotAllowed, + resolveOAuthMode, +} from './oauthMode.js'; describe('resolveOAuthMode', () => { it('defaults to both when the value is omitted', () => { @@ -55,4 +60,71 @@ describe('assertOAuthRegistrationAllowed', () => { }, ); }); + + 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 index 220460f8e..74ab2b05b 100644 --- a/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -25,11 +25,21 @@ export function resolveOAuthMode(value: unknown): OAuthMode { throw new GrpcError(status.INVALID_ARGUMENT, 'mode must be "signIn" or "both"'); } -export function assertOAuthRegistrationAllowed(mode: OAuthMode): void { +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; @@ -37,3 +47,34 @@ export function assertOAuthRegistrationAllowed(mode: OAuthMode): void { } } } + +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; + } +}