Skip to content

Commit 640ad1c

Browse files
committed
feat(proxy): resolve push identity from token via SCM provider API (#1400)
parsePush incorrectly uses the last commit's committer as the push user. This adds a new chain processor that extracts the token from HTTP Basic auth, calls the SCM provider's user API (GitHub GET /user for now), and maps the SCM login to a git-proxy user via the gitAccount field. - TokenIdentityProvider interface with hostname-based dispatch - GitHubTokenIdentityProvider calling api.github.com/user - resolveUserFromToken chain processor (non-blocking on failure) - findUserByGitAccount DB lookup (file + mongo) - GET/PUT /api/v1/user/:username/git-account endpoints
1 parent ca1d5aa commit 640ad1c

16 files changed

Lines changed: 937 additions & 0 deletions

File tree

src/db/file/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const {
3939
export const {
4040
findUser,
4141
findUserByEmail,
42+
findUserByGitAccount,
4243
findUserByOIDC,
4344
findUserBySSHKey,
4445
getUsers,

src/db/file/users.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ export const findUserByEmail = (email: string): Promise<User | null> => {
9292
});
9393
};
9494

95+
export const findUserByGitAccount = (gitAccount: string): Promise<User | null> => {
96+
return new Promise<User | null>((resolve, reject) => {
97+
db.findOne({ gitAccount: gitAccount.toLowerCase() }, (err: Error | null, doc: User) => {
98+
/* istanbul ignore if */
99+
if (err) {
100+
reject(err);
101+
} else {
102+
resolve(doc ?? null);
103+
}
104+
});
105+
});
106+
};
107+
95108
export const findUserByOIDC = function (oidcId: string): Promise<User | null> {
96109
return new Promise<User | null>((resolve, reject) => {
97110
db.findOne({ oidcId: oidcId }, (err: Error | null, doc: User) => {

src/db/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export const deleteRepo = (_id: string): Promise<void> => start().deleteRepo(_id
204204
export const findUser = (username: string): Promise<User | null> => start().findUser(username);
205205
export const findUserByEmail = (email: string): Promise<User | null> =>
206206
start().findUserByEmail(email);
207+
export const findUserByGitAccount = (gitAccount: string): Promise<User | null> =>
208+
start().findUserByGitAccount(gitAccount);
207209
export const findUserByOIDC = (oidcId: string): Promise<User | null> =>
208210
start().findUserByOIDC(oidcId);
209211
export const findUserBySSHKey = (sshKey: string): Promise<User | null> =>

src/db/mongo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const {
3939
export const {
4040
findUser,
4141
findUserByEmail,
42+
findUserByGitAccount,
4243
findUserByOIDC,
4344
findUserBySSHKey,
4445
getUsers,

src/db/mongo/users.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export const findUserByEmail = async function (email: string): Promise<User | nu
3434
return doc ? toClass(doc, User.prototype) : null;
3535
};
3636

37+
export const findUserByGitAccount = async function (gitAccount: string): Promise<User | null> {
38+
const collection = await connect(collectionName);
39+
const doc = await collection.findOne({ gitAccount: { $eq: gitAccount.toLowerCase() } });
40+
return doc ? toClass(doc, User.prototype) : null;
41+
};
42+
3743
export const findUserByOIDC = async function (oidcId: string): Promise<User | null> {
3844
const collection = await connect(collectionName);
3945
const doc = await collection.findOne({ oidcId: { $eq: oidcId } });

src/db/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export interface Sink {
138138
deleteRepo: (_id: string) => Promise<void>;
139139
findUser: (username: string) => Promise<User | null>;
140140
findUserByEmail: (email: string) => Promise<User | null>;
141+
findUserByGitAccount: (gitAccount: string) => Promise<User | null>;
141142
findUserByOIDC: (oidcId: string) => Promise<User | null>;
142143
findUserBySSHKey: (sshKey: string) => Promise<User | null>;
143144
getUsers: (query?: Partial<UserQuery>) => Promise<User[]>;

src/proxy/chain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { handleErrorAndLog } from '../utils/errors';
2424

2525
const pushActionChain: ((req: Request, action: Action) => Promise<Action>)[] = [
2626
proc.push.parsePush,
27+
proc.push.resolveUserFromToken,
2728
proc.push.checkEmptyBranch,
2829
proc.push.checkRepoInAuthorisedList,
2930
proc.push.checkCommitMessages,

src/proxy/processors/push-action/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth';
2929
import { exec as checkCommitMessages } from './checkCommitMessages';
3030
import { exec as checkAuthorEmails } from './checkAuthorEmails';
3131
import { exec as checkUserPushPermission } from './checkUserPushPermission';
32+
import { exec as resolveUserFromToken } from './resolveUserFromToken';
3233

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

@@ -47,5 +48,6 @@ export {
4748
checkCommitMessages,
4849
checkAuthorEmails,
4950
checkUserPushPermission,
51+
resolveUserFromToken,
5052
checkEmptyBranch,
5153
};
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Request } from 'express';
18+
19+
import { Action, Step } from '../../actions';
20+
import { getProviderForHost, scmTokenCache } from './tokenIdentity';
21+
import { findUserByGitAccount } from '../../../db';
22+
import { getErrorMessage } from '../../../utils/errors';
23+
24+
async function exec(req: Request, action: Action): Promise<Action> {
25+
const step = new Step('resolveUserFromToken');
26+
27+
if (req.user) {
28+
step.log(`User already resolved via session auth: ${action.user}`);
29+
action.addStep(step);
30+
return action;
31+
}
32+
33+
try {
34+
const authHeader = req.headers?.authorization;
35+
if (!authHeader) {
36+
step.log('No Authorization header — cannot resolve push identity from token');
37+
action.addStep(step);
38+
return action;
39+
}
40+
41+
const [scheme, encoded] = authHeader.split(' ');
42+
if (!scheme || !encoded || scheme.toLowerCase() !== 'basic') {
43+
step.log('Authorization header is not Basic — cannot resolve push identity');
44+
action.addStep(step);
45+
return action;
46+
}
47+
48+
const credentials = Buffer.from(encoded, 'base64').toString();
49+
const separatorIndex = credentials.indexOf(':');
50+
if (separatorIndex === -1) {
51+
step.log('Malformed Basic auth credentials');
52+
action.addStep(step);
53+
return action;
54+
}
55+
56+
const token = credentials.slice(separatorIndex + 1);
57+
58+
let hostname: string;
59+
try {
60+
hostname = new URL(action.url).hostname;
61+
} catch {
62+
step.log(`Cannot parse hostname from action URL: ${action.url}`);
63+
action.addStep(step);
64+
return action;
65+
}
66+
67+
const provider = getProviderForHost(hostname);
68+
if (!provider) {
69+
step.log(`No token identity provider for host '${hostname}' — identity resolution skipped`);
70+
action.addStep(step);
71+
return action;
72+
}
73+
74+
const cached = scmTokenCache.lookup(provider.name, token);
75+
if (cached) {
76+
step.log(`${provider.name}: resolved push identity from cache: ${cached}`);
77+
action.user = cached;
78+
action.addStep(step);
79+
return action;
80+
}
81+
82+
const identity = await provider.fetchScmIdentity(token);
83+
if (!identity) {
84+
step.log(
85+
`${provider.name}: failed to resolve identity from token (invalid token or missing scope?)`,
86+
);
87+
action.addStep(step);
88+
return action;
89+
}
90+
91+
step.log(
92+
`${provider.name}: resolved SCM identity from token: ${identity.login} (${identity.email ?? 'no public email'})`,
93+
);
94+
95+
const user = await findUserByGitAccount(identity.login);
96+
if (user) {
97+
step.log(
98+
`Mapped SCM identity '${identity.login}' to git-proxy user '${user.username}' (${user.email})`,
99+
);
100+
action.user = user.username;
101+
action.userEmail = user.email;
102+
scmTokenCache.store(provider.name, token, user.username);
103+
} else {
104+
step.log(
105+
`No git-proxy user has gitAccount '${identity.login}' — ` +
106+
`falling back to SCM identity. ` +
107+
`Users can associate their account via PUT /api/v1/user/:username/git-account`,
108+
);
109+
action.user = identity.login;
110+
if (identity.email) {
111+
action.userEmail = identity.email;
112+
}
113+
}
114+
} catch (error: unknown) {
115+
const msg = getErrorMessage(error);
116+
step.log(`Failed to resolve push identity from token: ${msg}`);
117+
}
118+
119+
action.addStep(step);
120+
return action;
121+
}
122+
123+
exec.displayName = 'resolveUserFromToken.exec';
124+
125+
export { exec };
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import crypto from 'crypto';
18+
19+
export type ScmUserInfo = {
20+
login: string;
21+
email?: string;
22+
};
23+
24+
type CacheEntry = { username: string; provider: string; cachedAt: number };
25+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
26+
27+
export class ScmTokenCache {
28+
private readonly cache = new Map<string, CacheEntry>();
29+
private readonly ttlMs: number;
30+
31+
constructor(ttlMs = DEFAULT_TTL_MS) {
32+
this.ttlMs = ttlMs;
33+
}
34+
35+
private key(provider: string, token: string): string {
36+
return crypto.createHash('sha512').update(`${provider}:${token}`).digest('hex');
37+
}
38+
39+
lookup(provider: string, token: string): string | null {
40+
const k = this.key(provider, token);
41+
const entry = this.cache.get(k);
42+
if (!entry) return null;
43+
if (Date.now() - entry.cachedAt > this.ttlMs) {
44+
this.cache.delete(k);
45+
return null;
46+
}
47+
return entry.username;
48+
}
49+
50+
store(provider: string, token: string, username: string): void {
51+
this.cache.set(this.key(provider, token), { username, provider, cachedAt: Date.now() });
52+
}
53+
54+
evictByUsername(provider: string, username: string): void {
55+
for (const [k, entry] of this.cache.entries()) {
56+
if (entry.username === username && entry.provider === provider) this.cache.delete(k);
57+
}
58+
}
59+
}
60+
61+
export const scmTokenCache = new ScmTokenCache();
62+
63+
export interface TokenIdentityProvider {
64+
readonly name: string;
65+
matches(hostname: string): boolean;
66+
fetchScmIdentity(token: string): Promise<ScmUserInfo | null>;
67+
}
68+
69+
type GitHubUserResponse = {
70+
login: string;
71+
id: number;
72+
email: string | null;
73+
};
74+
75+
export class GitHubTokenIdentityProvider implements TokenIdentityProvider {
76+
readonly name = 'github';
77+
78+
matches(hostname: string): boolean {
79+
return hostname === 'github.com';
80+
}
81+
82+
async fetchScmIdentity(token: string): Promise<ScmUserInfo | null> {
83+
try {
84+
const response = await fetch('https://api.github.com/user', {
85+
headers: {
86+
Authorization: `token ${token}`,
87+
Accept: 'application/vnd.github+json',
88+
},
89+
});
90+
91+
if (!response.ok) {
92+
console.warn(
93+
`GitHub /user API returned ${response.status} — token may be invalid or lack read:user scope`,
94+
);
95+
return null;
96+
}
97+
98+
const user: GitHubUserResponse = await response.json();
99+
return {
100+
login: user.login,
101+
email: user.email ?? undefined,
102+
};
103+
} catch (e) {
104+
console.warn(`Failed to fetch GitHub identity: ${e}`);
105+
return null;
106+
}
107+
}
108+
}
109+
110+
const providers: TokenIdentityProvider[] = [new GitHubTokenIdentityProvider()];
111+
112+
export function getProviderForHost(hostname: string): TokenIdentityProvider | null {
113+
return providers.find((p) => p.matches(hostname)) ?? null;
114+
}

0 commit comments

Comments
 (0)