Skip to content

Commit 9c3d053

Browse files
coopernetesclaude
andcommitted
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 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc23d58 commit 9c3d053

16 files changed

Lines changed: 633 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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 { TokenIdentityProvider, ScmUserInfo } from './TokenIdentityProvider';
18+
19+
type GitHubUserResponse = {
20+
login: string;
21+
id: number;
22+
email: string | null;
23+
};
24+
25+
export class GitHubTokenIdentityProvider implements TokenIdentityProvider {
26+
readonly name = 'github';
27+
28+
matches(hostname: string): boolean {
29+
return hostname === 'github.com';
30+
}
31+
32+
async fetchScmIdentity(token: string): Promise<ScmUserInfo | null> {
33+
try {
34+
const response = await fetch('https://api.github.com/user', {
35+
headers: {
36+
Authorization: `token ${token}`,
37+
Accept: 'application/vnd.github+json',
38+
},
39+
});
40+
41+
if (!response.ok) {
42+
console.warn(
43+
`GitHub /user API returned ${response.status} — token may be invalid or lack read:user scope`,
44+
);
45+
return null;
46+
}
47+
48+
const user: GitHubUserResponse = await response.json();
49+
return {
50+
login: user.login,
51+
email: user.email ?? undefined,
52+
};
53+
} catch (e) {
54+
console.warn(`Failed to fetch GitHub identity: ${e}`);
55+
return null;
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)