mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
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.
332 lines
11 KiB
Go
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),
|
|
})
|
|
}
|