Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/db/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const {
export const {
findUser,
findUserByEmail,
findUserByGitAccount,
findUserByOIDC,
findUserBySSHKey,
getUsers,
Expand Down
13 changes: 13 additions & 0 deletions src/db/file/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ export const findUserByEmail = (email: string): Promise<User | null> => {
});
};

export const findUserByGitAccount = (gitAccount: string): Promise<User | null> => {
return new Promise<User | null>((resolve, reject) => {
db.findOne({ gitAccount: gitAccount.toLowerCase() }, (err: Error | null, doc: User) => {
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve(doc ?? null);
}
});
});
};

export const findUserByOIDC = function (oidcId: string): Promise<User | null> {
return new Promise<User | null>((resolve, reject) => {
db.findOne({ oidcId: oidcId }, (err: Error | null, doc: User) => {
Expand Down
2 changes: 2 additions & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export const deleteRepo = (_id: string): Promise<void> => start().deleteRepo(_id
export const findUser = (username: string): Promise<User | null> => start().findUser(username);
export const findUserByEmail = (email: string): Promise<User | null> =>
start().findUserByEmail(email);
export const findUserByGitAccount = (gitAccount: string): Promise<User | null> =>
start().findUserByGitAccount(gitAccount);
export const findUserByOIDC = (oidcId: string): Promise<User | null> =>
start().findUserByOIDC(oidcId);
export const findUserBySSHKey = (sshKey: string): Promise<User | null> =>
Expand Down
1 change: 1 addition & 0 deletions src/db/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const {
export const {
findUser,
findUserByEmail,
findUserByGitAccount,
findUserByOIDC,
findUserBySSHKey,
getUsers,
Expand Down
6 changes: 6 additions & 0 deletions src/db/mongo/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const findUserByEmail = async function (email: string): Promise<User | nu
return doc ? toClass(doc, User.prototype) : null;
};

export const findUserByGitAccount = async function (gitAccount: string): Promise<User | null> {
const collection = await connect(collectionName);
const doc = await collection.findOne({ gitAccount: { $eq: gitAccount.toLowerCase() } });
Comment thread
jescalada marked this conversation as resolved.
return doc ? toClass(doc, User.prototype) : null;
};

export const findUserByOIDC = async function (oidcId: string): Promise<User | null> {
const collection = await connect(collectionName);
const doc = await collection.findOne({ oidcId: { $eq: oidcId } });
Expand Down
1 change: 1 addition & 0 deletions src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export interface Sink {
deleteRepo: (_id: string) => Promise<void>;
findUser: (username: string) => Promise<User | null>;
findUserByEmail: (email: string) => Promise<User | null>;
findUserByGitAccount: (gitAccount: string) => Promise<User | null>;
findUserByOIDC: (oidcId: string) => Promise<User | null>;
findUserBySSHKey: (sshKey: string) => Promise<User | null>;
getUsers: (query?: Partial<UserQuery>) => Promise<User[]>;
Expand Down
1 change: 1 addition & 0 deletions src/proxy/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { handleErrorAndLog } from '../utils/errors';
import { createProgressWriter } from './sideband';

const branchPushChain: Processor['exec'][] = [
proc.push.resolveUserFromToken,
proc.push.checkEmptyBranch,
proc.push.checkRepoInAuthorisedList,
proc.push.checkMessages,
Expand Down
2 changes: 2 additions & 0 deletions src/proxy/processors/push-action/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth';
import { exec as checkMessages } from './checkMessages';
import { exec as checkAuthorEmails } from './checkAuthorEmails';
import { exec as checkUserPushPermission } from './checkUserPushPermission';
import { exec as resolveUserFromToken } from './resolveUserFromToken';

import { exec as checkEmptyBranch } from './checkEmptyBranch';

Expand All @@ -45,5 +46,6 @@ export {
checkMessages,
checkAuthorEmails,
checkUserPushPermission,
resolveUserFromToken,
checkEmptyBranch,
};
120 changes: 120 additions & 0 deletions src/proxy/processors/push-action/resolveUserFromToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Request } from 'express';

import { Action, Step } from '../../actions';
import { getProviderForHost, scmTokenCache } from './tokenIdentity';
import { findUserByGitAccount } from '../../../db';
import { getErrorMessage } from '../../../utils/errors';

async function exec(req: Request, action: Action): Promise<Action> {
const step = new Step('resolveUserFromToken');

if (req.user) {
step.log(`User already resolved via session auth: ${action.user}`);
action.addStep(step);
return action;
}

try {
const authHeader = req.headers?.authorization;
if (!authHeader) {
step.log('No Authorization header — cannot resolve push identity from token');
action.addStep(step);
return action;
}

const [scheme, encoded] = authHeader.split(' ');
if (!scheme || !encoded || scheme.toLowerCase() !== 'basic') {
Comment thread
coopernetes marked this conversation as resolved.
step.log('Authorization header is not Basic — cannot resolve push identity');
action.addStep(step);
return action;
}

const credentials = Buffer.from(encoded, 'base64').toString();
const separatorIndex = credentials.indexOf(':');
if (separatorIndex === -1) {
step.log('Malformed Basic auth credentials');
action.addStep(step);
return action;
}

const token = credentials.slice(separatorIndex + 1);

let hostname: string;
try {
hostname = new URL(action.url).hostname;
} catch {
step.log(`Cannot parse hostname from action URL: ${action.url}`);
action.addStep(step);
return action;
}

const provider = getProviderForHost(hostname);
if (!provider) {
step.log(`No token identity provider for host '${hostname}' — identity resolution skipped`);
action.addStep(step);
return action;
}

const cached = scmTokenCache.lookup(provider.name, token);
if (cached) {
step.log(`${provider.name}: resolved push identity from cache: ${cached}`);
action.user = cached;
action.addStep(step);
return action;
}

const identity = await provider.fetchScmIdentity(token);
if (!identity) {
step.log(
`${provider.name}: failed to resolve identity from token (invalid token or missing scope?)`,
);
action.addStep(step);
return action;
}

step.log(`${provider.name}: resolved SCM identity from token: ${identity.login}`);

const user = await findUserByGitAccount(identity.login);
if (user) {
step.log(
`Mapped SCM identity '${identity.login}' to git-proxy user '${user.username}' (${user.email})`,
);
action.user = user.username;
action.userEmail = user.email;
scmTokenCache.store(provider.name, token, user.username);
} else {
step.log(
`No git-proxy user has gitAccount '${identity.login}' — ` +
`falling back to SCM identity. ` +
`Users can associate their account via POST /api/auth/gitAccount`,
);
action.user = identity.login;
}
} catch (error: unknown) {
const msg = getErrorMessage(error);
step.log(`Failed to resolve push identity from token: ${msg}`);
}

action.addStep(step);
return action;
}

exec.displayName = 'resolveUserFromToken.exec';

export { exec };
114 changes: 114 additions & 0 deletions src/proxy/processors/push-action/tokenIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import crypto from 'crypto';

export type ScmUserInfo = {
login: string;
};

type CacheEntry = { username: string; provider: string; cachedAt: number };
// 7 days — PATs are rarely rotated more frequently than this in practice; the cache is a
// rate-limit optimization only (keys are one-way SHA-512 hashes, not recoverable tokens).
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;

export class ScmTokenCache {
private readonly cache = new Map<string, CacheEntry>();
private readonly ttlMs: number;

constructor(ttlMs = DEFAULT_TTL_MS) {
this.ttlMs = ttlMs;
}

private key(provider: string, token: string): string {
return crypto.createHash('sha512').update(`${provider}:${token}`).digest('hex');
}

lookup(provider: string, token: string): string | null {
const k = this.key(provider, token);
const entry = this.cache.get(k);
if (!entry) return null;
if (Date.now() - entry.cachedAt > this.ttlMs) {
this.cache.delete(k);
return null;
}
entry.cachedAt = Date.now();
return entry.username;
}

store(provider: string, token: string, username: string): void {
this.cache.set(this.key(provider, token), { username, provider, cachedAt: Date.now() });
}

evictByUsername(provider: string, username: string): void {
for (const [k, entry] of this.cache.entries()) {
if (entry.username === username && entry.provider === provider) this.cache.delete(k);
}
}
}

export const scmTokenCache = new ScmTokenCache();

export interface TokenIdentityProvider {
readonly name: string;
matches(hostname: string): boolean;
fetchScmIdentity(token: string): Promise<ScmUserInfo | null>;
}

type GitHubUserResponse = {
login: string;
};

export class GitHubTokenIdentityProvider implements TokenIdentityProvider {
readonly name = 'github';

matches(hostname: string): boolean {
return hostname === 'github.com';
}

async fetchScmIdentity(token: string): Promise<ScmUserInfo | null> {
try {
const response = await fetch('https://api.github.com/user', {
Comment thread
coopernetes marked this conversation as resolved.
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(5000),
});

if (!response.ok) {
console.warn(
`GitHub /user API returned ${response.status} — token may be invalid or lack read:user scope`,
);
return null;
}

const user: GitHubUserResponse = await response.json();
return {
login: user.login,
};
} catch (e) {
console.warn(`Failed to fetch GitHub identity: ${e}`);
return null;
}
}
}

const providers: TokenIdentityProvider[] = [new GitHubTokenIdentityProvider()];

export function getProviderForHost(hostname: string): TokenIdentityProvider | null {
return providers.find((p) => p.matches(hostname)) ?? null;
}
2 changes: 2 additions & 0 deletions src/service/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { User } from '../../db/types';
import { AuthenticationElement } from '../../config/generated/config';
import { isAdminUser, mustChangePassword, toPublicUser } from './utils';
import { handleErrorAndLog } from '../../utils/errors';
import { scmTokenCache } from '../../proxy/processors/push-action/tokenIdentity';

const router = express.Router();
const passport = getPassport();
Expand Down Expand Up @@ -325,6 +326,7 @@ router.post('/gitAccount', async (req: Request, res: Response) => {

user.gitAccount = req.body.gitAccount;
await db.updateUser(user);
scmTokenCache.evictByUsername('github', user.username);
return res.status(200).send({ message: 'Git account updated successfully' }).end();
} catch (error: unknown) {
const msg = handleErrorAndLog(error, 'Failed to update git account');
Expand Down
1 change: 1 addition & 0 deletions test/chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const initMockPushProcessors = () => {
checkMessages: vi.fn(),
checkAuthorEmails: vi.fn(),
checkUserPushPermission: vi.fn(),
resolveUserFromToken: vi.fn(),
checkIfWaitingAuth: vi.fn(),
checkHiddenCommits: vi.fn(),
pullRemote: vi.fn(),
Expand Down
Loading
Loading