mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
325 lines
13 KiB
Go
325 lines
13 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
// A Hanzo IAM access token IS a git credential here.
|
|
//
|
|
// Sign-in on this deployment is external — ENABLE_PASSWORD_SIGNIN_FORM is off and
|
|
// registration is external-only — so a user HAS no password to hand git over
|
|
// https, and the only credential left was a personal access token they had to go
|
|
// and make. That is a second credential, with a second lifetime and a second
|
|
// revocation, for an identity IAM already issues tokens for. This reads the one
|
|
// they already hold.
|
|
//
|
|
// WHAT IS CHECKED. The same reader every Hanzo service uses (hanzoai/authz over
|
|
// the issuer's JWKS): algorithm, key id, signature, issuer, expiry. Issuer and
|
|
// keys come from THIS instance's own OIDC login source, so the tokens accepted are
|
|
// the ones minted by the identity provider users already sign in through, and
|
|
// changing that provider changes this with it.
|
|
//
|
|
// WHOSE TOKEN IT IS is answered by the link the sign-in already wrote: the token's
|
|
// `sub` against external_login_user for that source. No claim is trusted to NAME a
|
|
// user — not the email, not the username — because those are mutable and a match
|
|
// on one would let a renamed or re-registered identity land on somebody else's
|
|
// account. A token whose subject was never linked here resolves to nobody.
|
|
//
|
|
// WHAT IT MAY DO is write:repository — clone, fetch, push. It is deliberately not
|
|
// the whole API: a credential that exists so a checkout works has no business
|
|
// minting tokens, adding keys, or administering anything, and the sandbox that
|
|
// carries one runs code its owner submitted.
|
|
//
|
|
// The AUDIENCE is not checked, matching the platform reader's documented position:
|
|
// IAM sets `aud` to the client that ASKED for the token, not to the server that may
|
|
// accept it, so checking it here would mean enumerating every client in the estate
|
|
// and 401ing every user of the next one.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/authz/edge"
|
|
"github.com/hanzoai/builder"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
"github.com/hanzoai/git/models/db"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/auth/httpauth"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
)
|
|
|
|
// IAMTokenMethodName names this credential in the request's LoginMethod, so an
|
|
// audit of who pushed what can tell it from a personal access token.
|
|
const IAMTokenMethodName = "iam_token"
|
|
|
|
// jwksTTL is how long verified key material is reused. IAM rotates signing certs
|
|
// rarely and a rotation is picked up within this window; it is the same order as
|
|
// every other reader of the same JWKS.
|
|
const jwksTTL = 15 * time.Minute
|
|
|
|
// quiet is how long a FAILED discovery is remembered. Without it an unreachable
|
|
// provider is re-dialled by every request that carries anything JWT-shaped, each
|
|
// waiting out the timeout while holding the lock the next one needs — one
|
|
// unreachable host turning into a queue. Short, because the provider coming back
|
|
// should not take a quarter of an hour to notice.
|
|
const quiet = 30 * time.Second
|
|
|
|
// reader holds the verifier built from a login source, and the source it was built
|
|
// from so a re-registered provider rebuilds it instead of verifying against the
|
|
// old one forever.
|
|
var reader struct {
|
|
sync.Mutex
|
|
verifier *edge.Verifier
|
|
sourceID int64
|
|
discover string
|
|
built time.Time
|
|
}
|
|
|
|
// iamUser resolves the user a Hanzo IAM access token names, or nil when the
|
|
// credential is not one — an empty string, a personal access token, anything this
|
|
// instance's identity provider did not sign, or a subject that was never linked
|
|
// here. Nil is "not applicable", never "authorized".
|
|
func iamUser(ctx context.Context, token string) *user_model.User {
|
|
// A JWT and nothing else. Personal access tokens and action tokens are hex, so
|
|
// this declines them without a lookup and without touching the network.
|
|
if strings.Count(token, ".") != 2 || !strings.HasPrefix(token, "ey") {
|
|
return nil
|
|
}
|
|
v, sourceID := verifier(ctx)
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
claims, err := v.VerifyRaw(token)
|
|
if err != nil {
|
|
// Trace, not Error: a credential that is not ours reaching here is ordinary,
|
|
// and the auth chain has other methods left to try.
|
|
log.Trace("IAM Authorization: token not accepted: %v", err)
|
|
return nil
|
|
}
|
|
if claims.Subject == "" {
|
|
return nil
|
|
}
|
|
u, err := subjectUser(ctx, sourceID, claims.Subject)
|
|
if err != nil {
|
|
log.Error("IAM Authorization: resolve subject: %v", err)
|
|
return nil
|
|
}
|
|
if u == nil {
|
|
log.Trace("IAM Authorization: subject is not bound to an account here")
|
|
return nil
|
|
}
|
|
// An account that may not sign in may not sign in HERE either. Every other
|
|
// source in this chain asks (db, ldap, signin), and skipping it would leave
|
|
// one credential that outlives a suspension: the password form is closed to
|
|
// them and their tokens can be revoked, but the IAM token they already hold
|
|
// would keep answering. Deactivation and prohibition are the same answer,
|
|
// and an organisation is not somebody who signs in at all.
|
|
if !u.IsIndividual() || !u.IsActive || u.ProhibitLogin {
|
|
log.Trace("IAM Authorization: account may not sign in")
|
|
return nil
|
|
}
|
|
return u
|
|
}
|
|
|
|
// subjectUser answers the account an IAM subject is bound to, or nil.
|
|
//
|
|
// Two bindings exist and both are exact matches on the immutable subject:
|
|
//
|
|
// - external_login_user (sourceID, sub) — what browser sign-in USED to write.
|
|
// The OAuth2 source and LinkAccountToUser are gone from this fork, and
|
|
// verifier() always reports source 0, so on the live instance this never
|
|
// matches. Kept so an instance that still has the rows keeps working.
|
|
// - user.login_source / user.login_name = sub — what the platform writes when
|
|
// it provisions an account (hanzo-inc/cloud forge/member.go Provision). This
|
|
// is the binding every current account actually has, and reading only the
|
|
// first one is why every IAM bearer answered "invalid username, password or
|
|
// token".
|
|
//
|
|
// Seat accounts are bound as "seat:"+sub and carry ProhibitLogin, so they never
|
|
// match here and, if they did, are refused below. That is deliberate.
|
|
func subjectUser(ctx context.Context, sourceID int64, sub string) (*user_model.User, error) {
|
|
link, ok, err := user_model.GetExternalLogin(ctx, sourceID, sub)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
return user_model.GetUserByID(ctx, link.UserID)
|
|
}
|
|
return boundUser(ctx, sub)
|
|
}
|
|
|
|
// boundUser finds the one individual whose login_name is sub under the configured
|
|
// [iam] LOGIN_SOURCE (or, when that is not numeric, under any OAuth2 login type).
|
|
// More than one match is ambiguous and resolves to nobody.
|
|
func boundUser(ctx context.Context, sub string) (*user_model.User, error) {
|
|
cond := builder.Eq{"login_name": sub, "type": user_model.UserTypeIndividual}
|
|
if id, err := strconv.ParseInt(strings.TrimSpace(setting.IAM.LoginSource), 10, 64); err == nil && id > 0 {
|
|
cond["login_source"] = id
|
|
} else {
|
|
cond["login_type"] = auth_model.OAuth2
|
|
}
|
|
var users []*user_model.User
|
|
if err := db.GetEngine(ctx).Where(cond).Limit(2).Find(&users); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(users) != 1 {
|
|
return nil, nil //nolint:nilnil // unbound or ambiguous
|
|
}
|
|
return users[0], nil
|
|
}
|
|
|
|
// verifier returns the reader for this instance's OIDC login source and that
|
|
// source's id, rebuilding it when the source changes or the cache lapses.
|
|
//
|
|
// The DISCOVERY document is the source of both the issuer and the key set, so the
|
|
// two can never be configured to disagree: whatever answers as the provider users
|
|
// sign in through is what signs the tokens accepted here.
|
|
func verifier(ctx context.Context) (*edge.Verifier, int64) {
|
|
reader.Lock()
|
|
defer reader.Unlock()
|
|
// The login SOURCE if this instance has one, otherwise the configured issuer.
|
|
//
|
|
// Verifying a bearer needs the issuer's public keys and nothing else — no
|
|
// client credential, no registered source. Requiring a source row here made
|
|
// every credential this instance can verify depend on the prerequisites of
|
|
// BROWSER SIGN-IN, so with no source configured a git push, a CI clone and a
|
|
// package pull were all unverifiable, though not one of them presents
|
|
// anything but a bearer.
|
|
discover, sourceID := "", int64(0)
|
|
if iss := strings.TrimRight(strings.TrimSpace(setting.IAM.Issuer), "/"); iss != "" {
|
|
discover = iss + "/.well-known/openid-configuration"
|
|
}
|
|
if discover == "" {
|
|
return nil, 0
|
|
}
|
|
fresh := reader.discover == discover && reader.sourceID == sourceID
|
|
if fresh && reader.verifier != nil && time.Since(reader.built) < jwksTTL {
|
|
return reader.verifier, reader.sourceID
|
|
}
|
|
if fresh && reader.verifier == nil && time.Since(reader.built) < quiet {
|
|
return nil, 0 // the last attempt failed and the provider is still being left alone
|
|
}
|
|
reader.verifier, reader.sourceID, reader.discover = nil, sourceID, discover
|
|
reader.built = time.Now()
|
|
issuer, jwks := discoverAt(ctx, discover)
|
|
if issuer == "" || jwks == "" {
|
|
return nil, 0
|
|
}
|
|
// No audience allowlist — see the file comment. The issuer list is what fails
|
|
// closed, and it always has exactly this instance's provider in it.
|
|
reader.verifier = edge.NewVerifier(jwks, []string{issuer}, nil, jwksTTL)
|
|
return reader.verifier, reader.sourceID
|
|
}
|
|
|
|
// discover reads the issuer and the key set address out of an OIDC discovery
|
|
// document. Both empty on any failure, which leaves the credential unresolved
|
|
// rather than verified against a guess.
|
|
func discoverAt(ctx context.Context, url string) (issuer, jwks string) {
|
|
c, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(c, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
log.Error("IAM discovery unreachable: %v", err)
|
|
return "", ""
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Error("IAM discovery: status %d", resp.StatusCode)
|
|
return "", ""
|
|
}
|
|
var doc struct {
|
|
Issuer string `json:"issuer"`
|
|
JWKSURI string `json:"jwks_uri"`
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil || json.Unmarshal(body, &doc) != nil {
|
|
log.Error("IAM discovery: unreadable document")
|
|
return "", ""
|
|
}
|
|
return strings.TrimRight(doc.Issuer, "/"), doc.JWKSURI
|
|
}
|
|
|
|
// IAM accepts a Hanzo IAM access token the way every other service in the estate
|
|
// takes one — as a Bearer.
|
|
//
|
|
// The credential was already accepted here, but only through HTTP Basic, because
|
|
// that is how git hands a password over https and the check was written where git
|
|
// arrives (basic.go). Every other Hanzo service reads a Bearer, so an API caller
|
|
// sent one and got 401 — indistinguishable from a bad token, while the SAME token
|
|
// as Basic answered 200. Nothing in the refusal named the scheme, so the shape of
|
|
// the credential looked like the problem when only its envelope was.
|
|
//
|
|
// Scope is unchanged: write:repository, decided in one place below. This method
|
|
// widens HOW the token may be presented, never WHAT it may do.
|
|
type IAM struct{}
|
|
|
|
var _ Method = &IAM{}
|
|
|
|
// Name returns the name of this authentication method.
|
|
func (*IAM) Name() string { return IAMTokenMethodName }
|
|
|
|
// Verify reads a Bearer (or ?token=) credential and accepts it when IAM signed it
|
|
// for a subject linked to an account here. Declines everything else so the rest of
|
|
// the chain still runs — a forge token reaching this method is not ours to answer.
|
|
func (*IAM) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) {
|
|
token, ok := parseToken(req)
|
|
if !ok {
|
|
return nil, nil //nolint:nilnil // the auth method is not applicable
|
|
}
|
|
u := iamUser(req.Context(), token)
|
|
if u == nil {
|
|
return nil, nil //nolint:nilnil // not an IAM token, or its subject is unlinked
|
|
}
|
|
log.Trace("IAM Authorization: Valid IAM token for user[%d]", u.ID)
|
|
setIAMTokenScope(store)
|
|
return u, nil
|
|
}
|
|
|
|
// setIAMTokenScope is the ONE place an IAM token's authority is stated, so the git
|
|
// path (basic.go) and the API path above cannot drift into granting different
|
|
// things for the same credential.
|
|
func setIAMTokenScope(store DataStore) {
|
|
store.GetData()["LoginMethod"] = IAMTokenMethodName
|
|
store.GetData()["IsApiToken"] = true
|
|
store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeWriteRepository
|
|
}
|
|
|
|
// parseToken pulls the presented bearer out of a request.
|
|
//
|
|
// It moved here from the deleted oauth2 method, which was its only other caller:
|
|
// with the forge no longer issuing tokens, reading one is something only the IAM
|
|
// verifier does. The query-string forms stay because a git client and a package
|
|
// client both use them where a header is awkward, and DISABLE_QUERY_AUTH_TOKEN
|
|
// still governs whether they are honoured.
|
|
func parseToken(req *http.Request) (string, bool) {
|
|
_ = req.ParseForm()
|
|
if !setting.DisableQueryAuthToken {
|
|
if token := req.Form.Get("token"); token != "" {
|
|
return token, true
|
|
}
|
|
if token := req.Form.Get("access_token"); token != "" {
|
|
return token, true
|
|
}
|
|
} else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" {
|
|
log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true")
|
|
}
|
|
if auHead := req.Header.Get("Authorization"); auHead != "" {
|
|
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
|
|
if ok && parsed.BearerToken != nil {
|
|
return parsed.BearerToken.Token, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|