-
Notifications
You must be signed in to change notification settings - Fork 167
feat(proxy): resolve push identity from token via SCM provider API #1604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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') { | ||
|
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 }; | ||
| 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', { | ||
|
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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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(); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we might be missing an auth check here:
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I noticed the |
||||||||||||||
| 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) { | ||||||||||||||
|
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', | ||||||||||||||
|
|
||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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?
gitAccountis 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: