Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions apps/code/src/main/services/auth/port-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ import {

@injectable()
export class TokenCipherPortAdapter implements IAuthTokenCipher {
encrypt(plaintext: string): string {
return encrypt(plaintext);
encrypt(plaintext: string): Promise<string> {
return Promise.resolve(encrypt(plaintext));
}

decrypt(encrypted: string): string | null {
return decrypt(encrypted);
decrypt(encrypted: string): Promise<string | null> {
return Promise.resolve(decrypt(encrypted));
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ function createPreferencePort(): IAuthPreferenceStore {
}

const identityCipher: IAuthTokenCipher = {
encrypt: (plaintext) => plaintext,
decrypt: (encrypted) => encrypted,
encrypt: (plaintext) => Promise.resolve(plaintext),
decrypt: (encrypted) => Promise.resolve(encrypted),
};

const mockLogger: RootLogger = {
Expand Down
56 changes: 32 additions & 24 deletions packages/core/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
? await this.applyOrgChange(session, newOrgId)
: session.orgProjectsMap;

this.commitSessionState(session, {
await this.commitSessionState(session, {
orgProjectsMap,
currentOrgId: newOrgId,
currentProjectId: projectId,
Expand All @@ -277,7 +277,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
orgId,
);

this.commitSessionState(session, {
await this.commitSessionState(session, {
orgProjectsMap,
currentOrgId: orgId,
currentProjectId,
Expand Down Expand Up @@ -326,14 +326,14 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
}
return orgProjects[0]?.id ?? null;
}
private commitSessionState(
private async commitSessionState(
prevSession: InMemorySession,
next: {
orgProjectsMap: OrgProjectsMap;
currentOrgId: string | null;
currentProjectId: number | null;
},
): void {
): Promise<void> {
this.session = {
...prevSession,
orgProjectsMap: next.orgProjectsMap,
Expand All @@ -343,7 +343,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
};

this.persistProjectPreference(this.session);
this.persistSession({
await this.persistSession({
Comment thread
gantoine marked this conversation as resolved.
Comment thread
gantoine marked this conversation as resolved.
refreshToken: this.session.refreshToken,
cloudRegion: this.session.cloudRegion,
selectedProjectId: next.currentProjectId,
Expand Down Expand Up @@ -413,7 +413,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
return;
}

const storedSession = this.resolveStoredSession();
const storedSession = await this.resolveStoredSession();
if (!storedSession) {
this.logger.warn("Stored auth session could not be decrypted");
this.authSession.clearCurrent();
Expand Down Expand Up @@ -500,7 +500,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
return this.refreshPromise;
}

const sessionInput = this.getSessionInputForRefresh();
const sessionInput = await this.getSessionInputForRefresh();
Comment thread
gantoine marked this conversation as resolved.
Outdated

const refreshAndSync = async (): Promise<InMemorySession> => {
let session: InMemorySession;
Expand Down Expand Up @@ -532,7 +532,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
return this.refreshPromise;
}

private getSessionInputForRefresh(): StoredSessionInput {
private async getSessionInputForRefresh(): Promise<StoredSessionInput> {
if (this.session) {
return {
refreshToken: this.session.refreshToken,
Expand All @@ -541,7 +541,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
};
}

const storedSession = this.resolveStoredSession();
const storedSession = await this.resolveStoredSession();
if (!storedSession) {
throw new NotAuthenticatedError();
}
Expand Down Expand Up @@ -794,7 +794,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
session: InMemorySession,
): Promise<void> {
this.persistProjectPreference(session);
this.persistSession({
await this.persistSession({
refreshToken: session.refreshToken,
cloudRegion: session.cloudRegion,
selectedProjectId: session.currentProjectId,
Expand All @@ -816,15 +816,15 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
void this.refreshOrgProjects();
}
}
private persistSession(input: {
private async persistSession(input: {
refreshToken: string;
cloudRegion: CloudRegion;
selectedProjectId: number | null;
}): void {
}): Promise<void> {
const priorSelected =
this.authSession.getCurrent()?.selectedProjectId ?? null;
this.authSession.saveCurrent({
refreshTokenEncrypted: this.cipher.encrypt(input.refreshToken),
refreshTokenEncrypted: await this.cipher.encrypt(input.refreshToken),
cloudRegion: input.cloudRegion,
selectedProjectId: input.selectedProjectId ?? priorSelected,
scopeVersion: OAUTH_SCOPE_VERSION,
Expand Down Expand Up @@ -1051,11 +1051,13 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
private handleResume = (): void => {
this.attemptSessionRecovery();
};
private resolveStoredSession(): StoredSessionInput | null {
private async resolveStoredSession(): Promise<StoredSessionInput | null> {
const stored = this.authSession.getCurrent();
if (!stored) return null;

const refreshToken = this.cipher.decrypt(stored.refreshTokenEncrypted);
const refreshToken = await this.cipher.decrypt(
stored.refreshTokenEncrypted,
);
if (!refreshToken) return null;

return {
Expand All @@ -1077,21 +1079,27 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
if (!stored) return;
if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return;

if (!this.resolveStoredSession()) return;

// Route through ensureValidSession so a refresh already in flight (e.g. the
// background bootstrap restore past its deadline) is shared instead of
// kicking a second concurrent token refresh that would burn the same
// rotating refresh token twice.
this.recoveryPromise = this.ensureValidSession()
.then(() => undefined)
// Claim the recovery slot synchronously so concurrent triggers (network
// regained + tab resume) don't both kick a token refresh; decryptability
// is now async (Web Crypto), so it's validated inside recoverSession.
this.recoveryPromise = this.recoverSession()
.catch((error) => {
this.logger.warn("Session recovery failed", { error });
})
.finally(() => {
this.recoveryPromise = null;
});
}
private async recoverSession(): Promise<void> {
// Bail before touching the network if the stored token can't be decrypted.
if (!(await this.resolveStoredSession())) return;

// Route through ensureValidSession so a refresh already in flight (e.g. the
// background bootstrap restore past its deadline) is shared instead of
// kicking a second concurrent token refresh that would burn the same
// rotating refresh token twice.
await this.ensureValidSession();
}

private refreshOrgProjects(): Promise<void> {
if (this.orgProjectsRefreshPromise) {
Expand Down Expand Up @@ -1157,7 +1165,7 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
null,
lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null,
});
this.commitSessionState(session, {
await this.commitSessionState(session, {
orgProjectsMap: map,
currentOrgId: session.currentOrgId,
currentProjectId,
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/auth/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,12 @@ export const AUTH_OAUTH_FLOW_SERVICE = Symbol.for(

/**
* Machine-bound symmetric cipher for the refresh token at rest. Desktop adapter
* wraps the existing encryption util (node:crypto + machine id).
* wraps the existing encryption util (node:crypto + machine id); the web adapter
* uses a non-extractable Web Crypto key (async), so the contract is async.
*/
export interface IAuthTokenCipher {
encrypt(plaintext: string): string;
decrypt(encrypted: string): string | null;
encrypt(plaintext: string): Promise<string>;
decrypt(encrypted: string): Promise<string | null>;
}

export const AUTH_TOKEN_CIPHER = Symbol.for("posthog.core.auth.tokenCipher");
Expand Down
Loading