Stop leaking the refresh token in the social SSO redirect URL (#23061)

The Google/Microsoft callback for a sign-in with no target workspace
redirected to `/sign-in-up?tokenPair={...}`, putting a 60-day refresh
token in a query string. Those persist in browser history, `Referer`
headers and access logs.

It now carries a single-use, 5-minute opaque token in the URL fragment,
which the frontend exchanges over POST. Browsers never send the fragment
on the wire, so the token stays out of access logs, proxies and
`Referer` headers entirely. Redemption claims the row with a `DELETE`
guarded on `revokedAt`/`deletedAt` being null, so concurrent requests
cannot each mint a refresh token and a revoked token cannot redeem.
Enterprise SSO (OIDC/SAML) already used a POST exchange and is
unchanged.

```mermaid
sequenceDiagram
  participant Browser
  participant Server
  participant DB

  Note over Browser,Server: before, the redirect carried access + 60-day refresh in ?tokenPair
  Browser->>Server: GET /auth/google/redirect
  Server->>DB: store sha256(token), expires in 5 min
  Server-->>Browser: 302 /sign-in-up#ssoExchangeToken=opaque
  Note over Browser: fragment never sent back to any server
  Browser->>Server: POST getAuthTokensFromSSOExchangeToken
  Server->>DB: guarded DELETE, single-use claim
  Server-->>Browser: access + refresh token, in the response body
```

Since the token is single-use, the refresh token is minted at redemption
instead of at callback, so an abandoned redirect leaves an inert expired
hash rather than a live credential.

Redemption lives in its own `SignInUpSSOExchangeTokenEffect` +
`useRedeemSSOExchangeToken`, mirroring the existing
`VerifyLoginTokenEffect` + `useVerifyLogin` pair, so
`SignInUpGlobalScopeFormEffect` only loses the vulnerable branch. Like
`useVerifyLogin`, the hook clears any stale token pair before
exchanging. The effect reads `window.location.hash` live and strips it
synchronously, which doubles as the StrictMode double-invocation latch.

Remaining exposure is the browser itself (history until the synchronous
strip, client-side scripts), same as any fragment-based OAuth response.
`loginToken` on the workspace-targeted branch still travels as
`/verify?loginToken=` and is replayable for 15 minutes; moving it to the
fragment too is a separate change.

A fast instance command adds a unique partial index on `("type",
"value")` for live SSO exchange tokens, so redemption is an index lookup
instead of a full scan of the shared token table and at most one row can
ever match.
This commit is contained in:
Raphaël Bosi
2026-07-27 14:58:42 +02:00
committed by GitHub
parent b81ca99162
commit 56245a35af
26 changed files with 1053 additions and 40 deletions
@@ -5,6 +5,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -13,6 +14,7 @@ import {
} from 'typeorm';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export enum AppTokenType {
@@ -24,9 +26,14 @@ export enum AppTokenType {
OnboardingInvitationToken = 'ONBOARDING_INVITATION_TOKEN',
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
EnterpriseValidityToken = 'ENTERPRISE_VALIDITY_TOKEN',
SSOExchangeToken = 'SSO_EXCHANGE_TOKEN',
}
@Entity({ name: 'appToken', schema: 'core' })
@Index('IDX_APP_TOKEN_TYPE_VALUE_SSO_EXCHANGE_UNIQUE', ['type', 'value'], {
unique: true,
where: `"type" = 'SSO_EXCHANGE_TOKEN' AND "deletedAt" IS NULL AND "revokedAt" IS NULL`,
})
export class AppTokenEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -86,5 +93,6 @@ export class AppTokenEntity {
clientId?: string;
codeChallenge?: string;
scope?: string;
authProvider?: AuthProviderEnum;
} | null;
}
@@ -9,6 +9,7 @@ import { ImpersonationAuthorizationService } from 'src/engine/core-modules/imper
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
@@ -114,6 +115,10 @@ describe('AuthResolver', () => {
provide: WorkspaceAgnosticTokenService,
useValue: {},
},
{
provide: SSOExchangeTokenService,
useValue: {},
},
{
provide: TransientTokenService,
useValue: {},
@@ -48,6 +48,7 @@ import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/toke
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -97,6 +98,7 @@ import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthToken } from './dto/auth-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { GetAuthTokensFromSSOExchangeTokenInput } from './dto/get-auth-tokens-from-sso-exchange-token.input';
import { LoginTokenDTO } from './dto/login-token.dto';
import { SignUpInNewWorkspaceInput } from './dto/sign-up-in-new-workspace.input';
import { SignUpInput } from './dto/sign-up.input';
@@ -133,6 +135,7 @@ export class AuthResolver {
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
private ssoExchangeTokenService: SSOExchangeTokenService,
private refreshTokenService: RefreshTokenService,
private signInUpService: SignInUpService,
private transientTokenService: TransientTokenService,
@@ -672,6 +675,35 @@ export class AuthResolver {
}
}
@Mutation(() => AuthTokens)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getAuthTokensFromSSOExchangeToken(
@Args()
{ ssoExchangeToken }: GetAuthTokensFromSSOExchangeTokenInput,
): Promise<AuthTokens> {
const { userId, authProvider } =
await this.ssoExchangeTokenService.validateAndConsumeSSOExchangeTokenOrThrow(
ssoExchangeToken,
);
return {
tokens: {
accessOrWorkspaceAgnosticToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
userId,
authProvider,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
userId,
authProvider,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
},
};
}
private async validateAndDecodeLoginToken(
loginToken: string,
): Promise<LoginTokenJwtPayload> {
@@ -0,0 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class GetAuthTokensFromSSOExchangeTokenInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
ssoExchangeToken: string;
}
@@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { type Repository } from 'typeorm';
import { AppPath } from 'twenty-shared/types';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
@@ -12,12 +13,14 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service';
import { type ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { buildUrlWithPathnameAndSearchParams } from 'src/engine/core-modules/domain/domain-server-config/utils/build-url-with-pathname-and-search-params.util';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@@ -49,8 +52,9 @@ describe('AuthService', () => {
let userWorkspaceService: UserWorkspaceService;
let workspaceInvitationService: WorkspaceInvitationService;
let permissionsService: PermissionsService;
let refreshTokenService: RefreshTokenService;
let signInUpServiceMock: jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
Pick<SignInUpService, 'validatePassword' | 'signUpWithoutWorkspace'>
>;
beforeEach(async () => {
@@ -90,11 +94,25 @@ describe('AuthService', () => {
},
{
provide: DomainServerConfigService,
useValue: {},
useValue: {
buildBaseUrl: jest.fn(({ pathname, searchParams, hash }) =>
buildUrlWithPathnameAndSearchParams({
baseUrl: new URL('https://app.twenty.com'),
pathname,
searchParams,
hash,
}),
),
},
},
{
provide: WorkspaceAgnosticTokenService,
useValue: {},
provide: SSOExchangeTokenService,
useValue: {
generateSSOExchangeToken: jest.fn().mockResolvedValue({
token: 'sso-exchange-token',
expiresAt: new Date(),
}),
},
},
{
provide: GuardRedirectService,
@@ -105,6 +123,7 @@ describe('AuthService', () => {
useValue: {
validatePassword: jest.fn().mockResolvedValue(undefined),
generateHash: jest.fn(),
signUpWithoutWorkspace: jest.fn(),
},
},
{
@@ -123,7 +142,9 @@ describe('AuthService', () => {
},
{
provide: RefreshTokenService,
useValue: {},
useValue: {
generateRefreshToken: jest.fn(),
},
},
{
provide: UserWorkspaceService,
@@ -137,6 +158,7 @@ describe('AuthService', () => {
provide: UserService,
useValue: {
hasUserAccessToWorkspaceOrThrow: jest.fn(),
findUserByEmailWithWorkspaces: jest.fn(),
},
},
{
@@ -208,8 +230,9 @@ describe('AuthService', () => {
getRepositoryToken(UserEntity),
);
permissionsService = module.get<PermissionsService>(PermissionsService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
signInUpServiceMock = module.get(SignInUpService) as jest.Mocked<
Pick<SignInUpService, 'validatePassword'>
Pick<SignInUpService, 'validatePassword' | 'signUpWithoutWorkspace'>
>;
});
@@ -676,4 +699,54 @@ describe('AuthService', () => {
expect(spyAuthSsoService).toHaveBeenCalledTimes(1);
});
});
describe('signInUpWithSocialSSO - redirect without a target workspace', () => {
const socialSSOUser: GoogleRequest['user'] = {
firstName: 'John',
lastName: 'Doe',
email: 'John.Doe@twenty.com',
picture: 'picture',
action: 'list-available-workspaces',
returnToPath: '/settings/profile',
};
beforeEach(() => {
jest
.spyOn(userService, 'findUserByEmailWithWorkspaces')
.mockResolvedValue({ id: 'user-id' } as UserEntity);
});
it('should not mint a refresh token nor put credentials in the query string', async () => {
const url = await service.signInUpWithSocialSSO(
socialSSOUser,
AuthProviderEnum.Google,
);
expect(refreshTokenService.generateRefreshToken).not.toHaveBeenCalled();
expect([...new URL(url).searchParams.keys()]).toEqual(['returnToPath']);
});
it('should redirect with the sso exchange token in the url fragment', async () => {
const url = new URL(
await service.signInUpWithSocialSSO(
socialSSOUser,
AuthProviderEnum.Google,
),
);
expect(
new URLSearchParams(url.hash.substring(1)).get('ssoExchangeToken'),
).toBe('sso-exchange-token');
expect(url.pathname).toBe(AppPath.SignInUp);
});
it('should not sign the user up again when they already exist', async () => {
await service.signInUpWithSocialSSO(
socialSSOUser,
AuthProviderEnum.Google,
);
expect(signInUpServiceMock.signUpWithoutWorkspace).not.toHaveBeenCalled();
});
});
});
@@ -45,7 +45,7 @@ import { type MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/m
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
@@ -79,7 +79,7 @@ import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
export class AuthService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
private readonly ssoExchangeTokenService: SSOExchangeTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly domainServerConfigService: DomainServerConfigService,
private readonly refreshTokenService: RefreshTokenService,
@@ -981,27 +981,22 @@ export class AuthService {
},
));
const ssoExchangeToken =
await this.ssoExchangeTokenService.generateSSOExchangeToken({
userId: user.id,
authProvider,
});
// The token rides in the fragment so it never reaches access logs,
// proxies or Referer headers: browsers keep it out of the request line.
const url = this.domainServerConfigService.buildBaseUrl({
pathname: AppPath.SignInUp,
searchParams: {
tokenPair: JSON.stringify({
accessOrWorkspaceAgnosticToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
userId: user.id,
authProvider,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
userId: user.id,
authProvider,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
}),
...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/')
? { returnToPath }
: {}),
},
hash: `ssoExchangeToken=${ssoExchangeToken.token}`,
});
return url.toString();
@@ -0,0 +1,185 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import crypto from 'crypto';
import { IsNull, Repository } from 'typeorm';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { SSOExchangeTokenService } from './sso-exchange-token.service';
const USER_ID = '20202020-9e3b-46d4-a556-88b9ddc2b034';
const sha256 = (value: string) =>
crypto.createHash('sha256').update(value).digest('hex');
describe('SSOExchangeTokenService', () => {
let service: SSOExchangeTokenService;
let twentyConfigService: TwentyConfigService;
let appTokenRepository: Repository<AppTokenEntity>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SSOExchangeTokenService,
{
provide: TwentyConfigService,
useValue: { get: jest.fn().mockReturnValue('5m') },
},
{
provide: getRepositoryToken(AppTokenEntity),
useClass: Repository,
},
],
}).compile();
service = module.get<SSOExchangeTokenService>(SSOExchangeTokenService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
appTokenRepository = module.get<Repository<AppTokenEntity>>(
getRepositoryToken(AppTokenEntity),
);
jest
.spyOn(appTokenRepository, 'create')
.mockImplementation((entity) => entity as AppTokenEntity);
jest
.spyOn(appTokenRepository, 'save')
.mockImplementation(async (entity) => entity as AppTokenEntity);
});
describe('generateSSOExchangeToken', () => {
it('should persist only the hash of the token, never the plaintext', async () => {
const { token } = await service.generateSSOExchangeToken({
userId: USER_ID,
authProvider: AuthProviderEnum.Google,
});
const savedToken = jest.mocked(appTokenRepository.save).mock
.calls[0][0] as AppTokenEntity;
expect(savedToken.value).toBe(sha256(token));
expect(savedToken.value).not.toBe(token);
expect(savedToken.type).toBe(AppTokenType.SSOExchangeToken);
expect(savedToken.userId).toBe(USER_ID);
expect(savedToken.context).toEqual({
authProvider: AuthProviderEnum.Google,
});
});
it('should use the short term token expiration', async () => {
await service.generateSSOExchangeToken({
userId: USER_ID,
authProvider: AuthProviderEnum.Microsoft,
});
expect(twentyConfigService.get).toHaveBeenCalledWith(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
});
it('should generate a different token on every call', async () => {
const first = await service.generateSSOExchangeToken({
userId: USER_ID,
authProvider: AuthProviderEnum.Google,
});
const second = await service.generateSSOExchangeToken({
userId: USER_ID,
authProvider: AuthProviderEnum.Google,
});
expect(first.token).not.toBe(second.token);
});
});
describe('validateAndConsumeSSOExchangeTokenOrThrow', () => {
const buildAppToken = (): AppTokenEntity =>
({
id: 'app-token-id',
userId: USER_ID,
type: AppTokenType.SSOExchangeToken,
value: sha256('plain-token'),
expiresAt: new Date(Date.now() + 60_000),
context: { authProvider: AuthProviderEnum.Google },
}) as AppTokenEntity;
const mockLookup = (appToken: AppTokenEntity | null) => {
jest
.spyOn(appTokenRepository, 'findOneBy')
.mockResolvedValue(appToken as never);
};
const mockClaim = (affected: number) => {
jest
.spyOn(appTokenRepository, 'delete')
.mockResolvedValue({ affected, raw: [] } as never);
};
it('should return the user and auth provider of the claimed token', async () => {
mockLookup(buildAppToken());
mockClaim(1);
const result =
await service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token');
expect(result).toEqual({
userId: USER_ID,
authProvider: AuthProviderEnum.Google,
});
expect(appTokenRepository.delete).toHaveBeenCalledWith({
id: 'app-token-id',
revokedAt: IsNull(),
deletedAt: IsNull(),
});
});
it('should throw when the token cannot be found', async () => {
mockLookup(null);
await expect(
service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'),
).rejects.toThrow(AuthException);
});
it('should throw when the delete does not claim the row', async () => {
mockLookup(buildAppToken());
mockClaim(0);
await expect(
service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'),
).rejects.toThrow(AuthException);
});
it('should throw when the claimed token has expired', async () => {
const expiredToken = buildAppToken();
expiredToken.expiresAt = new Date(Date.now() - 1);
mockLookup(expiredToken);
mockClaim(1);
await expect(
service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'),
).rejects.toThrow(AuthException);
});
it('should throw when the auth provider is missing from the token context', async () => {
const tokenWithoutProvider = buildAppToken();
tokenWithoutProvider.context = null;
mockLookup(tokenWithoutProvider);
mockClaim(1);
await expect(
service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'),
).rejects.toThrow(AuthException);
});
});
});
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { msg } from '@lingui/core/macro';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
const hashSSOExchangeToken = (ssoExchangeToken: string) =>
crypto.createHash('sha256').update(ssoExchangeToken).digest('hex');
// A single opaque error for missing, expired and already-consumed tokens:
// distinguishing them would turn this endpoint into a redemption oracle.
const buildInvalidSSOExchangeTokenException = () =>
new AuthException(
'Invalid SSO exchange token',
AuthExceptionCode.INVALID_INPUT,
{ userFriendlyMessage: msg`Authentication failed, please sign in again.` },
);
@Injectable()
export class SSOExchangeTokenService {
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateSSOExchangeToken({
userId,
authProvider,
}: {
userId: string;
authProvider: AuthProviderEnum;
}): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const plainToken = crypto.randomBytes(32).toString('hex');
await this.appTokenRepository.save(
this.appTokenRepository.create({
userId,
expiresAt,
type: AppTokenType.SSOExchangeToken,
value: hashSSOExchangeToken(plainToken),
context: { authProvider },
}),
);
return {
token: plainToken,
expiresAt,
};
}
async validateAndConsumeSSOExchangeTokenOrThrow(
ssoExchangeToken: string,
): Promise<{ userId: string; authProvider: AuthProviderEnum }> {
const appToken = await this.appTokenRepository.findOneBy({
value: hashSSOExchangeToken(ssoExchangeToken),
type: AppTokenType.SSOExchangeToken,
revokedAt: IsNull(),
deletedAt: IsNull(),
});
if (!isDefined(appToken)) {
throw buildInvalidSSOExchangeTokenException();
}
// Deleting the row is the single-use claim: under concurrent redemption
// only the request whose delete affects the row proceeds to mint a token.
// Re-checking revokedAt/deletedAt here keeps the claim atomic with
// revocation: a token revoked after the lookup cannot redeem.
const { affected } = await this.appTokenRepository.delete({
id: appToken.id,
revokedAt: IsNull(),
deletedAt: IsNull(),
});
if (affected !== 1) {
throw buildInvalidSSOExchangeTokenException();
}
if (new Date() > appToken.expiresAt) {
throw buildInvalidSSOExchangeTokenException();
}
if (
!isDefined(appToken.userId) ||
!isDefined(appToken.context?.authProvider)
) {
throw buildInvalidSSOExchangeTokenException();
}
return {
userId: appToken.userId,
authProvider: appToken.context.authProvider,
};
}
}
@@ -10,6 +10,7 @@ import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/serv
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -44,6 +45,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
SSOExchangeTokenService,
],
exports: [
RenewTokenService,
@@ -52,6 +54,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
SSOExchangeTokenService,
],
})
export class TokenModule {}
@@ -46,14 +46,17 @@ export class DomainServerConfigService {
buildBaseUrl({
pathname,
searchParams,
hash,
}: {
pathname?: string;
searchParams?: Record<string, string | number>;
hash?: string;
}) {
return buildUrlWithPathnameAndSearchParams({
baseUrl: this.getBaseUrl(),
pathname,
searchParams,
hash,
});
}