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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions packages/auth/src/__tests__/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createHmacAccessTokenService } from '../access-token-service';
import { createAuthService } from '../auth.service';
import { ERROR_MESSAGES } from '../constants';
import type { AuthRepository, EmailService } from '../types';
import type { Session } from '../repositories/types';
import { sha256 } from '@orkait/crypto/hash';
import { randomBytes } from '@orkait/crypto/random';

Expand All @@ -33,6 +34,7 @@ class MemoryAuthRepository implements AuthRepository {
users = new Map<string, User>();
refreshTokens = new Map<string, RefreshToken>();
emailVerificationTokens = new Map<string, EmailVerificationToken>();
sessions = new Map<string, Session>();
failOnGetUserByEmail = false;
failOnRotateRefreshToken = false;
failOnConsumeEmailVerificationToken = false;
Expand Down Expand Up @@ -185,6 +187,94 @@ class MemoryAuthRepository implements AuthRepository {
}
}
}

async createSession(session: Session): Promise<void> {
this.sessions.set(session.id, { ...session });
}

async createSessionWithToken(session: Session, token: RefreshToken): Promise<void> {
this.sessions.set(session.id, { ...session });
this.refreshTokens.set(token.tokenHash, { ...token });
}

async updateSession(id: string, updates: Partial<Session>): Promise<void> {
const current = this.sessions.get(id);
if (current) {
this.sessions.set(id, { ...current, ...updates });
}
}

async getSessionByRefreshTokenHash(tokenHash: string): Promise<Session | null> {
return (
Array.from(this.sessions.values()).find(
(s) => s.refreshTokenHash === tokenHash && !s.revokedAt
) ?? null
);
}

async getUserSessions(userId: string): Promise<Session[]> {
return Array.from(this.sessions.values()).filter(
(s) => s.userId === userId && !s.revokedAt
);
}

async revokeSession(id: string): Promise<void> {
const current = this.sessions.get(id);
if (current) {
this.sessions.set(id, { ...current, revokedAt: Date.now() });
}
}

async revokeUserSessions(userId: string): Promise<void> {
for (const [id, session] of this.sessions.entries()) {
if (session.userId === userId && !session.revokedAt) {
this.sessions.set(id, { ...session, revokedAt: Date.now() });
}
}
}

async rotateSessionToken(oldTokenHash: string, nextToken: RefreshToken): Promise<boolean> {
if (this.failOnRotateRefreshToken) {
throw new Error('Failed to rotate refresh token');
}

const current = this.refreshTokens.get(oldTokenHash);
if (!current || current.revokedAt) {
return false;
}

const session = Array.from(this.sessions.values()).find(
(s) => s.refreshTokenHash === oldTokenHash && !s.revokedAt
);

this.refreshTokens.set(nextToken.tokenHash, { ...nextToken });
this.refreshTokens.set(oldTokenHash, { ...current, revokedAt: nextToken.createdAt });

if (session) {
this.sessions.set(session.id, {
...session,
refreshTokenHash: nextToken.tokenHash,
updatedAt: nextToken.createdAt,
});
}

return true;
}

async revokeTokenAndSession(tokenHash: string): Promise<void> {
const now = Date.now();
const token = this.refreshTokens.get(tokenHash);
if (token) {
this.refreshTokens.set(tokenHash, { ...token, revokedAt: token.revokedAt ?? now });
}

const session = Array.from(this.sessions.values()).find(
(s) => s.refreshTokenHash === tokenHash && !s.revokedAt
);
if (session) {
this.sessions.set(session.id, { ...session, revokedAt: now });
}
}
}

class MemoryEmailService implements EmailService {
Expand Down Expand Up @@ -400,4 +490,138 @@ describe('AuthService', () => {
expect(userBefore?.emailVerified).toBe(false);
expect(userAfter?.emailVerified).toBe(false);
});

it('creates a session on login', async () => {
const service = createService();
await service.signup({
email: 'session-login@example.com',
password: 'correct horse battery staple',
});

repository.sessions.clear();

const login = await service.login({
email: 'session-login@example.com',
password: 'correct horse battery staple',
});

expect(login.success).toBe(true);
expect(repository.sessions.size).toBe(1);

const session = Array.from(repository.sessions.values())[0]!;
expect(session.userId).toBe(login.data!.user.id);
expect(session.service).toBe('auth');
expect(session.revokedAt).toBeNull();
expect(session.refreshTokenHash).toBeTruthy();
});

it('revokes the session on logout', async () => {
const service = createService();
const signup = await service.signup({
email: 'session-logout@example.com',
password: 'correct horse battery staple',
});

expect(signup.success).toBe(true);
expect(repository.sessions.size).toBe(1);

const sessionBefore = Array.from(repository.sessions.values())[0]!;
expect(sessionBefore.revokedAt).toBeNull();

await service.logout(signup.data!.refreshToken);

const sessionAfter = repository.sessions.get(sessionBefore.id)!;
expect(sessionAfter.revokedAt).not.toBeNull();
});

it('revokes all sessions on logoutAll', async () => {
const service = createService();
const signup = await service.signup({
email: 'session-logout-all@example.com',
password: 'correct horse battery staple',
});

expect(signup.success).toBe(true);

const login = await service.login({
email: 'session-logout-all@example.com',
password: 'correct horse battery staple',
});

expect(login.success).toBe(true);
expect(repository.sessions.size).toBe(2);

const activeBefore = Array.from(repository.sessions.values()).filter((s) => !s.revokedAt);
expect(activeBefore).toHaveLength(2);

await service.logoutAll(signup.data!.user.id);

const activeAfter = Array.from(repository.sessions.values()).filter((s) => !s.revokedAt);
expect(activeAfter).toHaveLength(0);
});

it('embeds session_id in the access token JWT', async () => {
const service = createService();
const signup = await service.signup({
email: 'jwt-session@example.com',
password: 'correct horse battery staple',
});

expect(signup.success).toBe(true);

const payload = await service.verifyAccessToken(signup.data!.accessToken);
expect(typeof payload?.session_id).toBe('string');
expect(payload!.session_id!.length).toBeGreaterThan(0);
expect(payload!.session_id).toMatch(/^sess/);
});

it('creates a session on googleAuth', async () => {
const googleClientId = 'test-google-client-id';
const service = createAuthService(
{
repository,
accessTokenService: createHmacAccessTokenService({
secret: randomBytes(32),
}),
emailService,
},
{
clock,
accessTokenExpiresInSeconds: 300,
refreshTokenExpiresInSeconds: 600,
googleClientId,
}
);

const idToken = 'fake-google-id-token';
const googleSub = 'google-sub-12345';
const googleEmail = 'google-user@example.com';

const mockFetch = async () => ({
ok: true,
json: async () => ({
aud: googleClientId,
iss: 'https://accounts.google.com',
sub: googleSub,
email: googleEmail,
name: 'Google User',
}),
});

const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch as typeof fetch;

try {
const result = await service.googleAuth(idToken);
expect(result.success).toBe(true);
expect(repository.sessions.size).toBe(1);

const session = Array.from(repository.sessions.values())[0]!;
expect(session.userId).toBe(result.data!.user.id);
expect(session.refreshTokenHash).toBeTruthy();
expect(session.revokedAt).toBeNull();
} finally {
globalThis.fetch = originalFetch;
}
});
});
5 changes: 3 additions & 2 deletions packages/auth/src/access-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { JWTPayload, User } from '@orkait/common';
import { JWTService } from '@orkait/crypto/jwt';

export interface AccessTokenService {
signAccessToken(user: Pick<User, 'id' | 'email'>, expiresInSeconds: number): Promise<string>;
signAccessToken(user: Pick<User, 'id' | 'email'>, expiresInSeconds: number, sessionId?: string): Promise<string>;
verifyAccessToken(token: string): Promise<JWTPayload | null>;
}

Expand All @@ -23,11 +23,12 @@ export function createHmacAccessTokenService(
});

return {
async signAccessToken(user, expiresInSeconds) {
async signAccessToken(user, expiresInSeconds, sessionId?) {
return service.signJWT(
{
sub: user.id,
email: user.email,
...(sessionId ? { session_id: sessionId } : {}),
},
{ expiresInSeconds }
);
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/auth.builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function buildLocalUser(params: {
status: AUTH_USER_STATUS.ACTIVE,
createdAt: params.now,
updatedAt: params.now,
lastLoginAt: null,
lastLoginAt: params.now,
failedLoginCount: 0,
lockedUntil: null,
};
Expand Down
Loading
Loading