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.
620 lines
20 KiB
Go
620 lines
20 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// authzRequest is a validated authorization request. It is carried through the login and consent
|
|
// forms as a sealed (HMAC-protected) value, so the server stays stateless between the steps.
|
|
type authzRequest struct {
|
|
ClientID string `json:"cid"`
|
|
RedirectURI string `json:"ru"`
|
|
State string `json:"st,omitempty"`
|
|
Nonce string `json:"n,omitempty"`
|
|
CodeChallenge string `json:"cc"`
|
|
ResponseMode string `json:"rm,omitempty"`
|
|
Scopes []string `json:"sc,omitempty"`
|
|
Prompt []string `json:"pr,omitempty"`
|
|
MaxAge int `json:"ma"` // -1 = not requested
|
|
IDTokenHint string `json:"ith,omitempty"`
|
|
LoginHint string `json:"lh,omitempty"`
|
|
ACRValues []string `json:"acr,omitempty"`
|
|
Claims map[string]any `json:"cl,omitempty"`
|
|
Resource []string `json:"res,omitempty"`
|
|
Provider string `json:"pv,omitempty"`
|
|
DPoPJKT string `json:"dj,omitempty"`
|
|
ViaPAR bool `json:"par,omitempty"`
|
|
|
|
LoginDone bool `json:"ld,omitempty"`
|
|
ConsentDone bool `json:"cd,omitempty"`
|
|
Bind string `json:"b,omitempty"` // sid the consent form is bound to
|
|
Tx string `json:"tx,omitempty"` // browser binding (login CSRF)
|
|
Sess *ssoSession `json:"ss,omitempty"` // only when the SSO cookie is disabled
|
|
}
|
|
|
|
func (r *authzRequest) hasPrompt(p string) bool { return oauthSliceContains(r.Prompt, p) }
|
|
|
|
var pkceChallengeRE = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
|
|
|
|
const txCookie = "resolvespec_oauth_tx"
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Authorization endpoint — GET + POST /oauth/authorize
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) authorizeHandler(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.authorizeGet(w, r)
|
|
case http.MethodPost:
|
|
s.authorizePost(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// authzDirectError answers an error that must not be redirected (the client or redirect_uri is
|
|
// not trusted): JSON for API callers, a page for browsers.
|
|
func (s *OAuthServer) authzDirectError(w http.ResponseWriter, r *http.Request, code, desc string) {
|
|
if strings.Contains(r.Header.Get("Accept"), "text/html") {
|
|
s.renderMessage(w, http.StatusBadRequest, "Authorization error", desc, true)
|
|
return
|
|
}
|
|
writeOAuthError(w, code, desc, http.StatusBadRequest)
|
|
}
|
|
|
|
func (s *OAuthServer) authorizeGet(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
viaPAR := false
|
|
if uri := q.Get("request_uri"); uri != "" {
|
|
pushed, e := s.resolvePushedRequest(r.Context(), q.Get("client_id"), uri)
|
|
if e != nil {
|
|
s.authzDirectError(w, r, e.Code, e.Desc)
|
|
return
|
|
}
|
|
q, viaPAR = pushed, true
|
|
}
|
|
req, fail := s.parseAuthz(r.Context(), q, viaPAR)
|
|
if fail != nil {
|
|
fail.respond(s, w, r)
|
|
return
|
|
}
|
|
s.continueAuthorize(w, r, req)
|
|
}
|
|
|
|
// authorizePost handles the login form, the consent form and, for compatibility, the legacy
|
|
// login form that repeated the request parameters as hidden fields.
|
|
func (s *OAuthServer) authorizePost(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var req *authzRequest
|
|
if blob := r.PostFormValue("req"); blob != "" {
|
|
req = &authzRequest{}
|
|
if err := s.open("authz", blob, req); err != nil {
|
|
s.renderMessage(w, http.StatusBadRequest, "Request expired", "This sign-in request has expired. Please go back to the application and start again.", true)
|
|
return
|
|
}
|
|
if !s.txMatches(r, req) {
|
|
s.renderMessage(w, http.StatusBadRequest, "Request rejected", "The browser session of this request does not match. Please start again.", true)
|
|
return
|
|
}
|
|
} else {
|
|
form := url.Values{}
|
|
for k, v := range r.PostForm {
|
|
form[k] = v
|
|
}
|
|
if form.Get("state") == "" {
|
|
form.Set("state", form.Get("client_state"))
|
|
}
|
|
if form.Get("response_type") == "" {
|
|
form.Set("response_type", "code")
|
|
}
|
|
var fail *authzFailure
|
|
if req, fail = s.parseAuthz(r.Context(), form, false); fail != nil {
|
|
fail.respond(s, w, r)
|
|
return
|
|
}
|
|
}
|
|
if r.PostFormValue("decision") != "" {
|
|
s.consentSubmit(w, r, req)
|
|
return
|
|
}
|
|
s.loginSubmit(w, r, req)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Parsing and validation
|
|
// --------------------------------------------------------------------------
|
|
|
|
// authzFailure is an invalid authorization request. When req is set the client and redirect_uri
|
|
// are trusted and the error goes back to the client; otherwise it is shown directly.
|
|
type authzFailure struct {
|
|
req *authzRequest
|
|
code string
|
|
desc string
|
|
}
|
|
|
|
func (f *authzFailure) respond(s *OAuthServer, w http.ResponseWriter, r *http.Request) {
|
|
if f.req == nil {
|
|
s.authzDirectError(w, r, f.code, f.desc)
|
|
return
|
|
}
|
|
s.authzRedirectError(w, r, f.req, f.code, f.desc)
|
|
}
|
|
|
|
// redirectURIMatches compares exactly, except that a registered http loopback URI matches any port
|
|
// (RFC 8252 §7.3).
|
|
func redirectURIMatches(registered []string, uri string) bool {
|
|
if oauthSliceContains(registered, uri) {
|
|
return true
|
|
}
|
|
u, err := url.Parse(uri)
|
|
if err != nil || u.Scheme != "http" || !isLoopbackHost(u.Hostname()) {
|
|
return false
|
|
}
|
|
for _, reg := range registered {
|
|
ru, err := url.Parse(reg)
|
|
if err != nil || ru.Scheme != "http" || !isLoopbackHost(ru.Hostname()) {
|
|
continue
|
|
}
|
|
if ru.Hostname() == u.Hostname() && ru.Path == u.Path && ru.RawQuery == u.RawQuery {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isLoopbackHost(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1" }
|
|
|
|
func (s *OAuthServer) parseAuthz(ctx context.Context, q url.Values, viaPAR bool) (*authzRequest, *authzFailure) {
|
|
client, ok := s.lookupOrFetchClient(ctx, q.Get("client_id"))
|
|
if !ok {
|
|
return nil, &authzFailure{code: "invalid_client", desc: "unknown client_id"}
|
|
}
|
|
redirectURI := q.Get("redirect_uri")
|
|
if redirectURI == "" && len(client.RedirectURIs) == 1 {
|
|
redirectURI = client.RedirectURIs[0]
|
|
}
|
|
if !redirectURIMatches(client.RedirectURIs, redirectURI) {
|
|
return nil, &authzFailure{code: "invalid_request", desc: "redirect_uri not registered"}
|
|
}
|
|
|
|
req := &authzRequest{
|
|
ClientID: client.ClientID, RedirectURI: redirectURI, State: q.Get("state"), Nonce: q.Get("nonce"),
|
|
MaxAge: -1, IDTokenHint: q.Get("id_token_hint"), LoginHint: q.Get("login_hint"),
|
|
Provider: q.Get("provider"), DPoPJKT: q.Get("dpop_jkt"), ViaPAR: viaPAR,
|
|
ResponseMode: q.Get("response_mode"), CodeChallenge: q.Get("code_challenge"),
|
|
}
|
|
bad := func(code, desc string) (*authzRequest, *authzFailure) {
|
|
return nil, &authzFailure{req: req, code: code, desc: desc}
|
|
}
|
|
|
|
if req.ResponseMode != "" && req.ResponseMode != "query" && req.ResponseMode != "form_post" {
|
|
req.ResponseMode = ""
|
|
return bad("invalid_request", "unsupported response_mode")
|
|
}
|
|
if q.Get("response_type") != "code" {
|
|
return bad("unsupported_response_type", "only 'code' is supported")
|
|
}
|
|
if q.Get("request") != "" {
|
|
return bad("request_not_supported", "request objects are not supported")
|
|
}
|
|
if q.Get("request_uri") != "" && !viaPAR {
|
|
return bad("request_uri_not_supported", "use the pushed authorization request endpoint")
|
|
}
|
|
if (s.cfg.RequirePAR || client.RequirePAR) && !viaPAR {
|
|
return bad("invalid_request", "this client must use pushed authorization requests")
|
|
}
|
|
if req.CodeChallenge == "" {
|
|
return bad("invalid_request", "code_challenge required (PKCE S256)")
|
|
}
|
|
if m := q.Get("code_challenge_method"); m != "" && m != "S256" {
|
|
return bad("invalid_request", "only S256 code_challenge_method is supported")
|
|
}
|
|
if !pkceChallengeRE.MatchString(req.CodeChallenge) {
|
|
return bad("invalid_request", "code_challenge must be a base64url SHA-256 value")
|
|
}
|
|
|
|
requested := strings.Fields(q.Get("scope"))
|
|
req.Scopes = requested
|
|
if len(client.AllowedScopes) > 0 && len(requested) > 0 {
|
|
req.Scopes = nil
|
|
for _, sc := range requested {
|
|
if oauthSliceContains(client.AllowedScopes, sc) {
|
|
req.Scopes = append(req.Scopes, sc)
|
|
}
|
|
}
|
|
if len(req.Scopes) == 0 {
|
|
return bad("invalid_scope", "none of the requested scopes is allowed for this client")
|
|
}
|
|
}
|
|
|
|
req.Prompt = strings.Fields(q.Get("prompt"))
|
|
for _, p := range req.Prompt {
|
|
if !oauthSliceContains([]string{"none", "login", "consent", "select_account"}, p) {
|
|
return bad("invalid_request", "unsupported prompt value")
|
|
}
|
|
}
|
|
if req.hasPrompt("none") && len(req.Prompt) > 1 {
|
|
return bad("invalid_request", "prompt=none cannot be combined with other values")
|
|
}
|
|
if v := q.Get("max_age"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
return bad("invalid_request", "max_age must be a non-negative integer")
|
|
}
|
|
req.MaxAge = n
|
|
}
|
|
if v := q.Get("claims"); v != "" {
|
|
if err := json.Unmarshal([]byte(v), &req.Claims); err != nil {
|
|
return bad("invalid_request", "claims must be a JSON object")
|
|
}
|
|
}
|
|
req.ACRValues = strings.Fields(q.Get("acr_values"))
|
|
for _, res := range q["resource"] {
|
|
u, err := url.Parse(res)
|
|
if err != nil || !u.IsAbs() || u.Fragment != "" {
|
|
return bad("invalid_target", "resource must be an absolute URI without fragment")
|
|
}
|
|
req.Resource = append(req.Resource, res)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Flow
|
|
// --------------------------------------------------------------------------
|
|
|
|
func userIDOfLogin(ctx context.Context, a *DatabaseAuthenticator, resp *LoginResponse) int {
|
|
if resp.User != nil && resp.User.UserID != 0 {
|
|
return resp.User.UserID
|
|
}
|
|
if info, err := a.OAuthIntrospectToken(ctx, resp.Token); err == nil && info.Active {
|
|
id, _ := strconv.Atoi(info.Sub)
|
|
return id
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// continueAuthorize runs the authorization pipeline for the browser's current SSO session.
|
|
func (s *OAuthServer) continueAuthorize(w http.ResponseWriter, r *http.Request, req *authzRequest) {
|
|
s.continueWith(w, r, req, s.ssoFromRequest(r))
|
|
}
|
|
|
|
func (s *OAuthServer) continueWith(w http.ResponseWriter, r *http.Request, req *authzRequest, sso *ssoSession) {
|
|
client, ok := s.lookupOrFetchClient(r.Context(), req.ClientID)
|
|
if !ok {
|
|
s.authzDirectError(w, r, "invalid_client", "unknown client_id")
|
|
return
|
|
}
|
|
|
|
needLogin := sso == nil
|
|
if sso != nil && !req.LoginDone {
|
|
switch {
|
|
case req.hasPrompt("login"):
|
|
needLogin = true
|
|
case req.MaxAge >= 0 && time.Now().Unix()-sso.AuthTime > int64(req.MaxAge):
|
|
needLogin = true
|
|
case req.IDTokenHint != "":
|
|
if sub, _ := s.hintSubject(req.IDTokenHint); sub != "" && sub != strconv.Itoa(sso.UserID) {
|
|
needLogin = true
|
|
}
|
|
}
|
|
}
|
|
if needLogin {
|
|
if req.hasPrompt("none") {
|
|
s.authzRedirectError(w, r, req, "login_required", "no authenticated session")
|
|
return
|
|
}
|
|
switch {
|
|
case s.hasProviders():
|
|
s.redirectToExternalProvider(w, r, req)
|
|
case s.auth != nil:
|
|
s.renderLogin(w, r, req, client, "")
|
|
default:
|
|
http.Error(w, "no authentication provider configured", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
need, err := s.consentRequired(r.Context(), req, client, sso.UserID)
|
|
if err != nil {
|
|
s.authzRedirectError(w, r, req, "server_error", "could not evaluate consent")
|
|
return
|
|
}
|
|
if need {
|
|
if req.hasPrompt("none") {
|
|
s.authzRedirectError(w, r, req, "consent_required", "user consent is required")
|
|
return
|
|
}
|
|
s.renderConsent(w, r, req, client, sso)
|
|
return
|
|
}
|
|
s.issueCode(w, r, req, sso)
|
|
}
|
|
|
|
// txBinding sets the browser binding cookie and returns its value.
|
|
func (s *OAuthServer) txBinding(w http.ResponseWriter, r *http.Request) string {
|
|
if c, err := r.Cookie(txCookie); err == nil && len(c.Value) >= 16 {
|
|
return c.Value
|
|
}
|
|
v, err := randomOAuthToken()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows the issuer scheme (cookieSecure)
|
|
Name: txCookie, Value: v, Path: "/", MaxAge: 900, HttpOnly: true,
|
|
Secure: s.cookieSecure(), SameSite: http.SameSiteLaxMode})
|
|
return v
|
|
}
|
|
|
|
func (s *OAuthServer) txMatches(r *http.Request, req *authzRequest) bool {
|
|
if req.Tx == "" {
|
|
return true
|
|
}
|
|
c, err := r.Cookie(txCookie)
|
|
return err == nil && c.Value == req.Tx
|
|
}
|
|
|
|
func (s *OAuthServer) sealRequest(w http.ResponseWriter, r *http.Request, req *authzRequest) (string, error) {
|
|
req.Tx = s.txBinding(w, r)
|
|
return s.seal("authz", req, 15*time.Minute)
|
|
}
|
|
|
|
func (s *OAuthServer) renderLogin(w http.ResponseWriter, r *http.Request, req *authzRequest, client *OAuthServerClient, errMsg string) {
|
|
state, err := s.sealRequest(w, r, req)
|
|
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: "authorize", State: state,
|
|
ClientName: client.ClientName, LoginHint: req.LoginHint,
|
|
})
|
|
}
|
|
|
|
// loginSubmit verifies the posted credentials and continues the pipeline.
|
|
func (s *OAuthServer) loginSubmit(w http.ResponseWriter, r *http.Request, req *authzRequest) {
|
|
client, ok := s.lookupOrFetchClient(r.Context(), req.ClientID)
|
|
if !ok {
|
|
s.authzDirectError(w, r, "invalid_client", "unknown client_id")
|
|
return
|
|
}
|
|
if s.auth == nil {
|
|
http.Error(w, "no authentication provider configured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
loginResp, err := s.auth.Login(r.Context(), LoginRequest{
|
|
Username: r.PostFormValue("username"),
|
|
Password: r.PostFormValue("password"),
|
|
})
|
|
if err != nil || loginResp == nil || loginResp.Token == "" {
|
|
msg := "Invalid username or password"
|
|
if loginResp != nil && loginResp.Requires2FA {
|
|
msg = "Two-factor authentication is not supported on this sign-in form"
|
|
}
|
|
s.renderLogin(w, r, req, client, msg)
|
|
return
|
|
}
|
|
userID := userIDOfLogin(r.Context(), s.auth, loginResp)
|
|
if userID == 0 {
|
|
s.renderLogin(w, r, req, client, "Invalid username or password")
|
|
return
|
|
}
|
|
sso, err := s.newSSO(loginResp.Token, userID, "")
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.setSSO(w, sso)
|
|
req.LoginDone = true
|
|
if s.cfg.SSOCookie.Disable {
|
|
req.Sess = sso
|
|
}
|
|
s.continueWith(w, r, req, sso)
|
|
}
|
|
|
|
// redirectToExternalProvider stores the request and sends the browser to the configured provider.
|
|
func (s *OAuthServer) redirectToExternalProvider(w http.ResponseWriter, r *http.Request, req *authzRequest) {
|
|
var provider *externalProvider
|
|
if req.Provider != "" {
|
|
if provider = s.providerByName(req.Provider); provider == nil {
|
|
http.Error(w, fmt.Sprintf("provider %q not found", req.Provider), http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else {
|
|
s.mu.RLock()
|
|
provider = &s.providers[0]
|
|
s.mu.RUnlock()
|
|
}
|
|
|
|
providerState, err := randomOAuthToken()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.pending[providerState] = &pendingAuth{Req: req, Provider: provider.providerName, ExpiresAt: time.Now().Add(10 * time.Minute)}
|
|
s.mu.Unlock()
|
|
|
|
authURL, err := provider.auth.OAuth2GetAuthURL(provider.providerName, providerState)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// External provider callback — GET {ProviderCallbackPath}
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) providerCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("code")
|
|
providerState := r.URL.Query().Get("state")
|
|
|
|
s.mu.Lock()
|
|
pending, ok := s.pending[providerState]
|
|
if ok {
|
|
delete(s.pending, providerState)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if !ok || time.Now().After(pending.ExpiresAt) {
|
|
http.Error(w, "invalid or expired state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if e := r.URL.Query().Get("error"); e != "" {
|
|
s.authzRedirectError(w, r, pending.Req, "access_denied", "the identity provider refused the request")
|
|
return
|
|
}
|
|
if code == "" {
|
|
http.Error(w, "missing code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
provider := s.providerByName(pending.Provider)
|
|
if provider == nil {
|
|
http.Error(w, fmt.Sprintf("provider %q not found", pending.Provider), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
loginResp, err := provider.auth.OAuth2HandleCallback(r.Context(), pending.Provider, code, providerState)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID := userIDOfLogin(r.Context(), provider.auth, loginResp)
|
|
if userID == 0 {
|
|
http.Error(w, "could not resolve the user", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sso, err := s.newSSO(loginResp.Token, userID, pending.Provider)
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.setSSO(w, sso)
|
|
req := pending.Req
|
|
req.LoginDone = true
|
|
if s.cfg.SSOCookie.Disable {
|
|
req.Sess = sso
|
|
}
|
|
s.continueWith(w, r, req, sso)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Code issuance and responses
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) saveCode(ctx context.Context, c *OAuthCode) error {
|
|
if s.cfg.PersistCodes && s.auth != nil {
|
|
return s.auth.OAuthSaveCode(ctx, c)
|
|
}
|
|
s.mu.Lock()
|
|
s.codes[c.Code] = c
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// takeCode returns and invalidates a code (single use).
|
|
func (s *OAuthServer) takeCode(ctx context.Context, code string) (*OAuthCode, bool) {
|
|
if s.cfg.PersistCodes && s.auth != nil {
|
|
c, err := s.auth.OAuthExchangeCode(ctx, code)
|
|
return c, err == nil
|
|
}
|
|
s.mu.Lock()
|
|
c, ok := s.codes[code]
|
|
if ok {
|
|
delete(s.codes, code)
|
|
}
|
|
s.mu.Unlock()
|
|
if !ok || time.Now().After(c.ExpiresAt) {
|
|
return nil, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func (s *OAuthServer) issueCode(w http.ResponseWriter, r *http.Request, req *authzRequest, sso *ssoSession) {
|
|
authCode, err := randomOAuthToken()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
acr := ""
|
|
if len(s.cfg.SupportedACR) > 0 {
|
|
acr = s.cfg.SupportedACR[0]
|
|
}
|
|
amr := []string{"pwd"}
|
|
if sso.Provider != "" {
|
|
amr = []string{"fed"}
|
|
}
|
|
c := &OAuthCode{
|
|
Code: authCode, ClientID: req.ClientID, RedirectURI: req.RedirectURI, ClientState: req.State,
|
|
CodeChallenge: req.CodeChallenge, CodeChallengeMethod: "S256",
|
|
SessionToken: sso.SID, Scopes: req.Scopes, ExpiresAt: time.Now().Add(s.cfg.AuthCodeTTL),
|
|
UserID: sso.UserID, Nonce: req.Nonce, AuthTime: sso.AuthTime, ACR: acr, AMR: amr, SessionID: sso.SID,
|
|
Claims: req.Claims, Resource: req.Resource, DPoPJKT: req.DPoPJKT, ResponseType: "code",
|
|
}
|
|
if err := s.saveCode(r.Context(), c); err != nil {
|
|
s.authzRedirectError(w, r, req, "server_error", "could not issue the authorization code")
|
|
return
|
|
}
|
|
s.noteClient(w, sso, req.ClientID)
|
|
s.authzRespond(w, r, req, map[string]string{"code": authCode})
|
|
}
|
|
|
|
func (s *OAuthServer) authzRedirectError(w http.ResponseWriter, r *http.Request, req *authzRequest, code, desc string) {
|
|
p := map[string]string{"error": code}
|
|
if desc != "" {
|
|
p["error_description"] = desc
|
|
}
|
|
s.authzRespond(w, r, req, p)
|
|
}
|
|
|
|
// authzRespond sends params to the client's redirect_uri (query or form_post) with state and the
|
|
// RFC 9207 iss parameter.
|
|
func (s *OAuthServer) authzRespond(w http.ResponseWriter, r *http.Request, req *authzRequest, params map[string]string) {
|
|
if req.State != "" {
|
|
params["state"] = req.State
|
|
}
|
|
params["iss"] = s.cfg.Issuer
|
|
if req.ResponseMode == "form_post" {
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
|
h.Set("Cache-Control", "no-store")
|
|
h.Set("Content-Security-Policy", "script-src 'unsafe-inline'; frame-ancestors 'none'")
|
|
var b strings.Builder
|
|
b.WriteString(`<!DOCTYPE html><html><body onload="document.forms[0].submit()"><form method="post" action="`)
|
|
b.WriteString(htmlEscape(req.RedirectURI))
|
|
b.WriteString(`">`)
|
|
for k, v := range params {
|
|
b.WriteString(`<input type="hidden" name="` + htmlEscape(k) + `" value="` + htmlEscape(v) + `">`)
|
|
}
|
|
b.WriteString(`<noscript><button type="submit">Continue</button></noscript></form></body></html>`)
|
|
w.Write([]byte(b.String())) //nolint:errcheck,gosec // G104: best-effort write, G705: values are HTML-escaped
|
|
return
|
|
}
|
|
u, err := url.Parse(req.RedirectURI)
|
|
if err != nil {
|
|
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
qp := u.Query()
|
|
for k, v := range params {
|
|
qp.Set(k, v)
|
|
}
|
|
u.RawQuery = qp.Encode()
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.Redirect(w, r, u.String(), http.StatusFound) //nolint:gosec // u is built from the registered redirect URI
|
|
}
|