Skip to content

Commit 4b14544

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 fc23d58 commit 4b14544

16 files changed

Lines changed: 802 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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 } 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 identity = await provider.fetchScmIdentity(token);
75+
if (!identity) {
76+
step.log(
77+
`${provider.name}: failed to resolve identity from token (invalid token or missing scope?)`,
78+
);
79+
action.addStep(step);
80+
return action;
81+
}
82+
83+
step.log(
84+
`${provider.name}: resolved SCM identity from token: ${identity.login} (${identity.email ?? 'no public email'})`,
85+
);
86+
87+
const user = await findUserByGitAccount(identity.login);
88+
if (user) {
89+
step.log(
90+
`Mapped SCM identity '${identity.login}' to git-proxy user '${user.username}' (${user.email})`,
91+
);
92+
action.user = user.username;
93+
action.userEmail = user.email;
94+
} else {
95+
step.log(
96+
`No git-proxy user has gitAccount '${identity.login}' — ` +
97+
`falling back to SCM identity. ` +
98+
`Users can associate their account via PUT /api/v1/user/:username/git-account`,
99+
);
100+
action.user = identity.login;
101+
if (identity.email) {
102+
action.userEmail = identity.email;
103+
}
104+
}
105+
} catch (error: unknown) {
106+
const msg = getErrorMessage(error);
107+
step.log(`Failed to resolve push identity from token: ${msg}`);
108+
}
109+
110+
action.addStep(step);
111+
return action;
112+
}
113+
114+
exec.displayName = 'resolveUserFromToken.exec';
115+
116+
export { exec };
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
export type ScmUserInfo = {
18+
login: string;
19+
email?: string;
20+
};
21+
22+
export interface TokenIdentityProvider {
23+
readonly name: string;
24+
matches(hostname: string): boolean;
25+
fetchScmIdentity(token: string): Promise<ScmUserInfo | null>;
26+
}
27+
28+
type GitHubUserResponse = {
29+
login: string;
30+
id: number;
31+
email: string | null;
32+
};
33+
34+
export class GitHubTokenIdentityProvider implements TokenIdentityProvider {
35+
readonly name = 'github';
36+
37+
matches(hostname: string): boolean {
38+
return hostname === 'github.com';
39+
}
40+
41+
async fetchScmIdentity(token: string): Promise<ScmUserInfo | null> {
42+
try {
43+
const response = await fetch('https://api.github.com/user', {
44+
headers: {
45+
Authorization: `token ${token}`,
46+
Accept: 'application/vnd.github+json',
47+
},
48+
});
49+
50+
if (!response.ok) {
51+
console.warn(
52+
`GitHub /user API returned ${response.status} — token may be invalid or lack read:user scope`,
53+
);
54+
return null;
55+
}
56+
57+
const user: GitHubUserResponse = await response.json();
58+
return {
59+
login: user.login,
60+
email: user.email ?? undefined,
61+
};
62+
} catch (e) {
63+
console.warn(`Failed to fetch GitHub identity: ${e}`);
64+
return null;
65+
}
66+
}
67+
}
68+
69+
const providers: TokenIdentityProvider[] = [new GitHubTokenIdentityProvider()];
70+
71+
export function getProviderForHost(hostname: string): TokenIdentityProvider | null {
72+
return providers.find((p) => p.matches(hostname)) ?? null;
73+
}

0 commit comments

Comments
 (0)