diff --git a/modules/authentication/package.json b/modules/authentication/package.json index 6dec4f51d..f69b3b8aa 100644 --- a/modules/authentication/package.json +++ b/modules/authentication/package.json @@ -16,7 +16,7 @@ "postbuild": "copyfiles -u 1 src/**/*.proto src/*.proto src/**/*.json ./dist/", "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" + "test": "tsc -p tsconfig.test.json && copyfiles -u 1 src/data/*.json dist-test/ && node --test dist-test/utils/emailRestrictions.test.js dist-test/handlers/oauth2/utils/oauthMode.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 fdb28c693..552f36938 100644 --- a/modules/authentication/src/handlers/oauth2/OAuth2.ts +++ b/modules/authentication/src/handlers/oauth2/OAuth2.ts @@ -23,7 +23,15 @@ 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 { errors } from '../../errors.js'; import { IAuthenticationStrategy } from '../../interfaces/index.js'; import { TokenType } from '../../constants/index.js'; import { @@ -91,6 +99,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), }, }) .catch(err => { @@ -144,6 +153,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), }, }) .catch(err => { @@ -200,16 +210,22 @@ export abstract class OAuth2 }); await Token.getInstance().deleteOne(stateToken); - const user = await this.createOrUpdateUser( - payload, - stateToken.data.invitationToken, - stateToken.data.anonymousUserId, - ); - ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); - 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); + } + ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); + return TokenProvider.getInstance().provideUserTokens( { user, @@ -234,6 +250,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; ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); @@ -249,6 +266,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)) { @@ -299,6 +317,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); } @@ -358,13 +377,14 @@ 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: { scopes: [ConduitString.Optional], invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, redirectUri: ConduitString.Optional, + mode: OAUTH_MODE_PARAM, }, middlewares: initRouteMiddleware, }, @@ -378,12 +398,13 @@ 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: { scopes: [ConduitString.Optional], invitationToken: ConduitString.Optional, captchaToken: ConduitString.Optional, + mode: OAUTH_MODE_PARAM, }, middlewares: initRouteMiddleware, }, @@ -403,6 +424,7 @@ export abstract class OAuth2 id_token: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { accessToken: ConduitString.Optional, @@ -422,6 +444,7 @@ export abstract class OAuth2 code: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { accessToken: ConduitString.Optional, @@ -439,6 +462,7 @@ export abstract class OAuth2 code: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], }, new ConduitRouteReturnDefinition(`${this.capitalizeProvider()}Response`, { accessToken: ConduitString.Optional, diff --git a/modules/authentication/src/handlers/oauth2/apple/apple.ts b/modules/authentication/src/handlers/oauth2/apple/apple.ts index a98d5f096..ca74c56b2 100644 --- a/modules/authentication/src/handlers/oauth2/apple/apple.ts +++ b/modules/authentication/src/handlers/oauth2/apple/apple.ts @@ -18,19 +18,24 @@ import axios from 'axios'; import { AppleUser } from './apple.user.js'; import jwt, { Jwt, JwtHeader, 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 moment from 'moment'; import jwksRsa from 'jwks-rsa'; import qs from 'querystring'; -import { validateStateToken } from '../utils/index.js'; +import { + redirectOnRegistrationNotAllowed, + resolveOAuthMode, + validateStateToken, +} from '../utils/index.js'; import { ConduitString, ConfigController, RoutingManager, } from '@conduitplatform/module-tools'; import { AuthUtils } from '../../../utils/index.js'; +import { errors } from '../../../errors.js'; export class AppleHandlers extends OAuth2 { constructor(grpcSdk: ConduitGrpcSdk, config: { apple: AppleProviderConfig }) { @@ -138,17 +143,23 @@ 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); - ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); - const redirectUri = AuthUtils.validateRedirectUri(stateToken.data.customRedirectUri) ?? this.settings.finalRedirect; + 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); + ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); const conduitClientId = stateToken.data.clientId; return TokenProvider.getInstance()!.provideUserTokens( @@ -236,6 +247,7 @@ export class AppleHandlers extends OAuth2 { userParams, stateToken.data.invitationToken, stateToken.data.anonymousUserId, + resolveOAuthMode(stateToken.data.mode), ); await Token.getInstance().deleteOne(stateToken); ConduitGrpcSdk.Metrics?.increment('logged_in_users_total'); @@ -261,6 +273,7 @@ export class AppleHandlers extends OAuth2 { id_token: ConduitString.Required, state: ConduitString.Required, }, + errors: [errors.REGISTRATION_NOT_ALLOWED], }, new ConduitRouteReturnDefinition(`AppleResponse`, { accessToken: ConduitString.Optional, diff --git a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts index 7d9b2ed2a..a1e0d168c 100644 --- a/modules/authentication/src/handlers/oauth2/facebook/facebook.ts +++ b/modules/authentication/src/handlers/oauth2/facebook/facebook.ts @@ -17,6 +17,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 { @@ -79,14 +81,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], }, new ConduitRouteReturnDefinition('FacebookResponse', { userId: ConduitString.Required, diff --git a/modules/authentication/src/handlers/oauth2/google/google.ts b/modules/authentication/src/handlers/oauth2/google/google.ts index 4fb85d6e3..8280ad15c 100644 --- a/modules/authentication/src/handlers/oauth2/google/google.ts +++ b/modules/authentication/src/handlers/oauth2/google/google.ts @@ -16,6 +16,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 { @@ -61,7 +63,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, @@ -69,8 +71,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], }, new ConduitRouteReturnDefinition('GoogleResponse', { userId: ConduitString.Required, diff --git a/modules/authentication/src/handlers/oauth2/utils/index.ts b/modules/authentication/src/handlers/oauth2/utils/index.ts index 0bfd51d50..3d3b76c9c 100644 --- a/modules/authentication/src/handlers/oauth2/utils/index.ts +++ b/modules/authentication/src/handlers/oauth2/utils/index.ts @@ -1,2 +1,3 @@ export * from './ValidateStateToken.js'; export * from './MakeRequest.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..49182a461 --- /dev/null +++ b/modules/authentication/src/handlers/oauth2/utils/oauthMode.ts @@ -0,0 +1,77 @@ +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.Optional; + +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 d928ee996..74e69b6c2 100644 --- a/modules/authentication/tsconfig.test.json +++ b/modules/authentication/tsconfig.test.json @@ -9,6 +9,8 @@ "include": [ "src/utils/emailRestrictions.ts", "src/utils/emailRestrictions.test.ts", + "src/handlers/oauth2/utils/oauthMode.ts", + "src/handlers/oauth2/utils/oauthMode.test.ts", "src/errors.ts", "src/data/disposable-email-domains.json" ],