mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 19:41:57 +00:00
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.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user