quple/server/auth.go
Philip Peterson 96f7134c51 Add contacts and per-call Jitsi servers
Everyone signs in at the one central Quine identity server and is called
by username. The caller picks which Jitsi Meet server each call happens
on (meet.jit.si by default, or any other deployment), and the invite
sends the callee to the same one. Jitsi servers need no credentials from
Quple.

- server: POST /call takes an optional jitsiUrl (http(s) only, no
  credentials/query/fragment; default DEFAULT_JITSI) and returns it with
  the meeting ID; invites carry it. Adds GET /me and per-person contacts
  with presence (persisted in DATA_DIR), replacing GET /users.
- desktop: "On server" field for calls, remembered as the last-used
  server; incoming calls show which server they're on; open_meeting
  refuses non-http(s) URLs. meet.jit.si sign-in is now an inline prompt
  when calling there rather than a gate in front of the whole app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:37:31 -08:00

87 lines
2.7 KiB
Go

package main
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
// Verifier checks Quine access tokens (JWTs from identity.quineglobal.com)
// locally with go-oidc. The issuer's discovery document and keys are fetched
// on first use rather than at startup, so this server can come up even while
// the identity server is down; go-oidc re-fetches keys when they rotate.
type Verifier struct {
issuer string
clientID string
mu sync.Mutex
verifier *oidc.IDTokenVerifier
lastTry time.Time
}
// discoveryRetryInterval bounds how often a failed discovery is retried, so
// a down identity server isn't hammered by every poll.
const discoveryRetryInterval = 10 * time.Second
var errIdentityUnavailable = errors.New("identity server unavailable")
func NewVerifier(issuer, clientID string) *Verifier {
return &Verifier{issuer: strings.TrimRight(issuer, "/"), clientID: clientID}
}
func (v *Verifier) get(ctx context.Context) (*oidc.IDTokenVerifier, error) {
v.mu.Lock()
defer v.mu.Unlock()
if v.verifier != nil {
return v.verifier, nil
}
if time.Since(v.lastTry) < discoveryRetryInterval {
return nil, errIdentityUnavailable
}
v.lastTry = time.Now()
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
provider, err := oidc.NewProvider(oidc.ClientContext(ctx, http.DefaultClient), v.issuer)
if err != nil {
return nil, errIdentityUnavailable
}
// Access tokens are issued with the client ID as their audience, which
// go-oidc checks along with issuer, expiry and signature.
v.verifier = provider.Verifier(&oidc.Config{ClientID: v.clientID})
return v.verifier, nil
}
// Verify checks an access token and returns the username it was issued to.
func (v *Verifier) Verify(ctx context.Context, token string) (string, error) {
verifier, err := v.get(ctx)
if err != nil {
return "", err
}
parsed, err := verifier.Verify(ctx, token)
if err != nil {
return "", err
}
var claims struct {
ClientID string `json:"client_id"`
JTI string `json:"jti"`
AtHash string `json:"at_hash"`
Nonce string `json:"nonce"`
}
if err := parsed.Claims(&claims); err != nil {
return "", err
}
// The identity server (zitadel/oidc) signs ID and access tokens alike -
// same audience, same typ header - so tell them apart by shape: access
// tokens have a jti and never the ID-token-only at_hash/nonce. This keeps
// an ID token from being accepted as an access token.
if claims.ClientID != v.clientID || claims.JTI == "" || claims.AtHash != "" || claims.Nonce != "" {
return "", errors.New("not an access token for this app")
}
// The subject is the Quine username.
return parsed.Subject, nil
}