Files
ResolveSpec/pkg/security/oauth_device.go
T
Hein 640faeeeaf feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
2026-10-01 14:42:12 +02:00

332 lines
11 KiB
Go

package security
import (
"crypto/rand"
"errors"
"net/http"
"strings"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
// userCodeAlphabet avoids vowels and look-alike characters (RFC 8628 §6.1).
const userCodeAlphabet = "BCDFGHJKLMNPQRSTVWXZ"
func newUserCode() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
out := make([]byte, 8)
for i, v := range b {
out[i] = userCodeAlphabet[int(v)%len(userCodeAlphabet)]
}
return string(out), nil
}
func normalizeUserCode(c string) string {
c = strings.ToUpper(c)
c = strings.ReplaceAll(c, "-", "")
return strings.ReplaceAll(c, " ", "")
}
func formatUserCode(c string) string {
if len(c) == 8 {
return c[:4] + "-" + c[4:]
}
return c
}
// filterScopes intersects the requested scopes with the client's allowed scopes.
func filterScopes(c *OAuthServerClient, requested []string) ([]string, bool) {
if len(c.AllowedScopes) == 0 || len(requested) == 0 {
return requested, true
}
var out []string
for _, sc := range requested {
if oauthSliceContains(c.AllowedScopes, sc) {
out = append(out, sc)
}
}
return out, len(out) > 0
}
// --------------------------------------------------------------------------
// RFC 8628 — device authorization: POST /oauth/device_authorization
// --------------------------------------------------------------------------
func (s *OAuthServer) deviceAuthorizationHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
writeOAuthError(w, "invalid_request", "cannot parse form", http.StatusBadRequest)
return
}
ac, e := s.requireClient(r)
if e != nil {
e.write(w)
return
}
client := ac.Client
if !grantAllowed(client, grantDeviceCode) {
writeOAuthError(w, "unauthorized_client", "client may not use the device grant", http.StatusBadRequest)
return
}
scopes, ok := filterScopes(client, strings.Fields(r.PostFormValue("scope")))
if !ok {
writeOAuthError(w, "invalid_scope", "none of the requested scopes is allowed for this client", http.StatusBadRequest)
return
}
gs := s.grants()
if gs == nil {
writeOAuthError(w, "server_error", "", http.StatusInternalServerError)
return
}
deviceCode, err := randomOAuthToken()
if err != nil {
writeOAuthError(w, "server_error", "", http.StatusInternalServerError)
return
}
var userCode string
for attempt := 0; ; attempt++ {
if userCode, err = newUserCode(); err == nil {
err = gs.CreateDevice(r.Context(), lookup.DeviceCode{
DeviceHash: hashToken(deviceCode), UserCode: userCode, ClientID: client.ClientID, Scopes: scopes,
Interval: s.cfg.DevicePollSeconds, ExpiresAt: time.Now().Add(s.cfg.DeviceCodeTTL),
})
}
if err == nil {
break
}
if attempt >= 3 { // a user-code collision is the only expected failure; give up after a few tries
writeOAuthError(w, "server_error", "", http.StatusInternalServerError)
return
}
}
verification := s.endpoint("/oauth/device")
writeJSON(w, http.StatusOK, map[string]any{
"device_code": deviceCode,
"user_code": formatUserCode(userCode),
"verification_uri": verification,
"verification_uri_complete": verification + "?user_code=" + formatUserCode(userCode),
"expires_in": int(s.cfg.DeviceCodeTTL.Seconds()),
"interval": s.cfg.DevicePollSeconds,
})
}
// --------------------------------------------------------------------------
// Verification page: GET/POST /oauth/device
// --------------------------------------------------------------------------
type deviceState struct {
UserCode string `json:"uc"`
Bind string `json:"b,omitempty"`
Tx string `json:"tx,omitempty"`
}
func (s *OAuthServer) deviceVerificationHandler(w http.ResponseWriter, r *http.Request) {
if s.cfg.SSOCookie.Disable {
s.renderMessage(w, http.StatusNotImplemented, "Not available", "The device flow needs the SSO cookie, which is disabled on this server.", true)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
if code := r.FormValue("user_code"); code != "" {
s.deviceNext(w, r, normalizeUserCode(code))
return
}
s.renderHTML(w, http.StatusOK, nil, "device", oauthDevicePage{Title: "Connect a device", Action: "device"})
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch r.PostFormValue("step") {
case "code":
s.deviceNext(w, r, normalizeUserCode(r.PostFormValue("user_code")))
case "login":
st, ok := s.openDeviceState(w, r, "device-login")
if !ok {
return
}
if s.auth == nil {
http.Error(w, "no authentication provider configured", http.StatusInternalServerError)
return
}
resp, err := s.auth.Login(r.Context(), LoginRequest{Username: r.PostFormValue("username"), Password: r.PostFormValue("password")})
uid := 0
if err == nil && resp != nil && resp.Token != "" {
uid = userIDOfLogin(r.Context(), s.auth, resp)
}
if uid == 0 {
s.renderDeviceLogin(w, r, st.UserCode, "Invalid username or password")
return
}
sso, err := s.newSSO(resp.Token, uid, "")
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
s.setSSO(w, sso)
s.deviceConsent(w, r, st.UserCode, sso)
case "decide":
st, ok := s.openDeviceState(w, r, "device-consent")
if !ok {
return
}
sso := s.ssoFromRequest(r)
if sso == nil || st.Bind != sso.SID {
s.renderMessage(w, http.StatusBadRequest, "Session expired", "Your session has expired. Start again from the device.", true)
return
}
err := s.grants().DeviceDecide(r.Context(), st.UserCode, r.PostFormValue("decision") == "allow", sso.UserID, sso.SID)
if err != nil {
s.renderMessage(w, http.StatusBadRequest, "Code not valid", "This code is unknown, expired or already used.", true)
return
}
if r.PostFormValue("decision") == "allow" {
s.renderMessage(w, http.StatusOK, "Device connected", "You can return to your device now.", false)
} else {
s.renderMessage(w, http.StatusOK, "Request denied", "The device was not given access.", false)
}
default:
http.Error(w, "invalid request", http.StatusBadRequest)
}
}
func (s *OAuthServer) openDeviceState(w http.ResponseWriter, r *http.Request, kind string) (*deviceState, bool) {
var st deviceState
if err := s.open(kind, r.PostFormValue("req"), &st); err != nil {
s.renderMessage(w, http.StatusBadRequest, "Request expired", "This request has expired. Enter the code again.", true)
return nil, false
}
if c, err := r.Cookie(txCookie); st.Tx != "" && (err != nil || c.Value != st.Tx) {
s.renderMessage(w, http.StatusBadRequest, "Request rejected", "The browser session of this request does not match.", true)
return nil, false
}
return &st, true
}
// deviceNext continues with the entered code: login when needed, then the approval screen.
func (s *OAuthServer) deviceNext(w http.ResponseWriter, r *http.Request, userCode string) {
if _, err := s.grants().DeviceByUserCode(r.Context(), userCode); err != nil {
code := http.StatusBadRequest
if !errors.Is(err, lookup.ErrNotFound) {
code = http.StatusInternalServerError
}
s.renderHTML(w, code, nil, "device", oauthDevicePage{Title: "Connect a device", Action: "device",
Error: "This code is unknown or has expired."})
return
}
if sso := s.ssoFromRequest(r); sso != nil {
s.deviceConsent(w, r, userCode, sso)
return
}
if s.auth == nil {
s.renderMessage(w, http.StatusNotImplemented, "Sign-in unavailable",
"Device sign-in needs the server's own login form, which is not configured.", true)
return
}
s.renderDeviceLogin(w, r, userCode, "")
}
func (s *OAuthServer) renderDeviceLogin(w http.ResponseWriter, r *http.Request, userCode, errMsg string) {
state, err := s.seal("device-login", deviceState{UserCode: userCode, Tx: s.txBinding(w, r)}, 15*time.Minute)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
s.renderHTML(w, http.StatusOK, s.tmpl.login, "login", OAuthLoginPage{
Title: s.cfg.LoginTitle, Error: errMsg, Action: "device", State: state, Hidden: map[string]string{"step": "login"},
})
}
func (s *OAuthServer) deviceConsent(w http.ResponseWriter, r *http.Request, userCode string, sso *ssoSession) {
dc, err := s.grants().DeviceByUserCode(r.Context(), userCode)
if err != nil {
s.renderMessage(w, http.StatusBadRequest, "Code not valid", "This code is unknown, expired or already used.", true)
return
}
client, ok := s.lookupOrFetchClient(r.Context(), dc.ClientID)
if !ok {
s.renderMessage(w, http.StatusBadRequest, "Unknown application", "The application that requested this code no longer exists.", true)
return
}
state, err := s.seal("device-consent", deviceState{UserCode: userCode, Bind: sso.SID, Tx: s.txBinding(w, r)}, 15*time.Minute)
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
name := client.ClientName
if name == "" {
name = client.ClientID
}
user := ""
if a := s.anyAuth(); a != nil {
if info, err := a.OAuthIntrospectToken(r.Context(), sso.Token); err == nil && info.Active {
user = info.Username
}
}
s.renderHTML(w, http.StatusOK, s.tmpl.consent, "consent", OAuthConsentPage{
Title: "Connect " + name, Action: "device", State: state, ClientName: name,
ClientURI: safeWebURL(client.ClientURI), LogoURI: safeWebURL(client.LogoURI),
Scopes: s.scopeInfos(dc.Scopes), User: user, Hidden: map[string]string{"step": "decide"},
})
}
// --------------------------------------------------------------------------
// Token endpoint side
// --------------------------------------------------------------------------
func (s *OAuthServer) handleDeviceGrant(r *http.Request) (map[string]any, *oauthError) {
ac, e := s.requireClient(r)
if e != nil {
return nil, e
}
client := ac.Client
if !grantAllowed(client, grantDeviceCode) {
return nil, oerr("unauthorized_client", "client may not use the device grant", http.StatusBadRequest)
}
code := r.PostFormValue("device_code")
if code == "" {
return nil, oerr("invalid_request", "device_code required", http.StatusBadRequest)
}
gs := s.grants()
if gs == nil {
return nil, serverErr()
}
dc, err := gs.DevicePoll(r.Context(), hashToken(code))
switch {
case errors.Is(err, lookup.ErrDevicePending):
return nil, oerr("authorization_pending", "", http.StatusBadRequest)
case errors.Is(err, lookup.ErrDeviceSlowDown):
return nil, oerr("slow_down", "", http.StatusBadRequest)
case errors.Is(err, lookup.ErrDeviceDenied):
return nil, oerr("access_denied", "", http.StatusBadRequest)
case errors.Is(err, lookup.ErrDeviceExpired):
return nil, oerr("expired_token", "", http.StatusBadRequest)
case err != nil:
return nil, serverErr()
}
if dc.ClientID != client.ClientID {
return nil, oerr("invalid_grant", "device_code was issued to another client", http.StatusBadRequest)
}
jkt, e := s.dpopForClient(r, client)
if e != nil {
return nil, e
}
return s.mintTokens(r.Context(), &tokenGrant{
Client: client, UserID: dc.UserID, Scopes: dc.Scopes, SID: dc.SessionToken, AuthTime: time.Now().Unix(),
AMR: []string{"pwd"}, DPoPJKT: jkt, IDToken: true, IssueRefresh: refreshAllowed(client, dc.Scopes),
})
}