Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions src/proxy/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import { createProgressWriter } from './sideband';

const branchPushChain: Processor['exec'][] = [
proc.push.parsePush,

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (24.x, 7.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (22.x, 7.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (24.x, 8.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (22.x, 6.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (24.x, 6.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-windows

Property 'parsePush' does not exist on type 'typeof import("D:/a/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.

Check failure on line 28 in src/proxy/chain.ts

View workflow job for this annotation

GitHub Actions / build-ubuntu (22.x, 8.0)

Property 'parsePush' does not exist on type 'typeof import("/home/runner/work/git-proxy/git-proxy/src/proxy/processors/push-action/index")'.
Comment thread
coopernetes marked this conversation as resolved.
Outdated
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 PUT /api/v1/user/:username/git-account`,
);
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;
}
52 changes: 52 additions & 0 deletions src/service/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import crypto from 'crypto';

import * as db from '../../db';
import { toPublicUser } from './utils';
import { scmTokenCache } from '../../proxy/processors/push-action/tokenIdentity';

const router = express.Router();

Expand Down Expand Up @@ -62,6 +63,57 @@ router.get('/:id', async (req: Request<{ id: string }>, res: Response) => {
res.send(toPublicUser(user));
});

// Get git account (SCM identity) for a user
router.get('/:username/git-account', async (req: Request<{ username: string }>, res: Response) => {
const targetUsername = req.params.username.toLowerCase();
Comment thread
coopernetes marked this conversation as resolved.
Outdated
const user = await db.findUser(targetUsername);
if (!user) {
res.status(404).json({ error: `User ${targetUsername} not found` });
return;
}
res.json({ username: user.username, gitAccount: user.gitAccount });
});

// Set git account (SCM identity) for a user
router.put('/:username/git-account', async (req: Request<{ username: string }>, res: Response) => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}

const { username, admin } = req.user as { username: string; admin: boolean };
const targetUsername = req.params.username.toLowerCase();

if (username !== targetUsername && !admin) {
Comment thread
coopernetes marked this conversation as resolved.
Outdated
res.status(403).json({ error: 'Not authorized to update git account for this user' });
return;
}

const { gitAccount } = req.body;
if (!gitAccount || typeof gitAccount !== 'string' || !gitAccount.trim()) {
res.status(400).json({ error: 'gitAccount is required' });
return;
}

const existing = await db.findUser(targetUsername);
if (!existing) {
res.status(404).json({ error: `User ${targetUsername} not found` });
return;
}

const conflict = await db.findUserByGitAccount(gitAccount.trim());
if (conflict && conflict.username !== targetUsername) {
res
.status(409)
.json({ error: `Git account '${gitAccount}' is already associated with another user` });
return;
}

await db.updateUser({ username: targetUsername, gitAccount: gitAccount.trim() });
scmTokenCache.evictByUsername('github', targetUsername);
res.json({ username: targetUsername, gitAccount: gitAccount.trim() });
});

// Get SSH key fingerprints for a user
router.get(
'/:username/ssh-key-fingerprints',
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