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
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 @@ -204,6 +204,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() } });

@coopernetes coopernetes Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any desire to support an list of accounts here? gitAccount is somewhat of a holdover from v1. It's also singular across the whole user context - there's no shape in the data model today that supports associative git account by upstream provider/hostname.

Ideally, we revisit this shape in support of this PR. Something like this:

# MongoDB doc
{
  # existing keys...
  "username": "git-proxy-user",
  "email": "user@corpo-example.com",
  "gitAccounts": {
    "github.com": ["foo", "bar"],
    "gitlab.com": [ "baz" ]
  }
}

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 @@ -138,6 +138,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 @@ -24,6 +24,7 @@ import { handleErrorAndLog } from '../utils/errors';

const pushActionChain: ((req: Request, action: Action) => Promise<Action>)[] = [
proc.push.parsePush,
proc.push.resolveUserFromToken,
proc.push.checkEmptyBranch,
proc.push.checkRepoInAuthorisedList,
proc.push.checkCommitMessages,
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 @@ -29,6 +29,7 @@ import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth';
import { exec as checkCommitMessages } from './checkCommitMessages';
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 @@ -47,5 +48,6 @@ export {
checkCommitMessages,
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 };
111 changes: 111 additions & 0 deletions src/proxy/processors/push-action/tokenIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* 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 };
const DEFAULT_TTL_MS = 5 * 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;
}
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might be missing an auth check here:

Suggested change
const targetUsername = req.params.username.toLowerCase();
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const targetUsername = req.params.username.toLowerCase();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new endpoint is consistent with the rest of the users Router endpoints. If auth is needed here across the suite of endpoints, let's track that in a separate issue+PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going by the SSH-related endpoints which all have the check, including GET /:username/ssh-key-fingerprints (although these are plugged into the UI).

I noticed the git-account endpoints are standalone at the moment. Should we add a UI form that interacts with these endpoints, and should the GET /:username/git-account be protected with the !req.user guard in that case?

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.
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 @@ -35,6 +35,7 @@ const initMockPushProcessors = () => {
checkCommitMessages: vi.fn(),
checkAuthorEmails: vi.fn(),
checkUserPushPermission: vi.fn(),
resolveUserFromToken: vi.fn(),
checkIfWaitingAuth: vi.fn(),
checkHiddenCommits: vi.fn(),
pullRemote: vi.fn(),
Expand Down
Loading
Loading