From c945cdea055174457dbcbf43fab2b931a710a8af Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 11:24:32 +0000 Subject: [PATCH] oidc: approve SSH checks on the confirmation page, not in the callback Require an explicit click before recording the SSH verdict. Recheck source ownership on the callback, confirmation page, and POST. --- hscontrol/oidc.go | 251 +++++++++++++----------- hscontrol/oidc_test.go | 160 +++++++++++++++ hscontrol/templates/register_confirm.go | 69 +++++-- hscontrol/types/common.go | 28 ++- 4 files changed, 356 insertions(+), 152 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 7ea8285ec..1a95efbbe 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -364,93 +364,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } - // If this is a registration flow, render the confirmation - // interstitial instead of finalising the registration immediately. - // Without an explicit user click, a single GET to - // /register/{auth_id} could silently complete a registration when - // the IdP allows silent SSO. - if authInfo.Registration { - a.beginRegistrationConfirmation(writer, req, authInfo.AuthID, user, nodeExpiry) - - return - } - - // If this is not a registration callback, then it is an SSH - // check-mode auth callback. Confirm the OIDC identity is the owner - // of the SSH source node before recording approval; without this - // check any tailnet user could approve a check-mode prompt for any - // other user's node, defeating the stolen-key protection that - // check-mode is meant to provide. - - authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID) - if !ok { - log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed") - httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil)) - - return - } - - if !authReq.IsSSHCheck() { - log.Warn().Caller(). - Str("auth_id", authInfo.AuthID.String()). - Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding") - httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil)) - - return - } - - binding := authReq.SSHCheckBinding() - - srcNode, ok := a.h.state.GetNodeByID(binding.SrcNodeID) - if !ok { - log.Warn().Caller(). - Str("auth_id", authInfo.AuthID.String()). - Uint64("src_node_id", binding.SrcNodeID.Uint64()). - Msg("SSH check src node no longer exists") - httpUserError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil)) - - return - } - - // Strict identity binding: only the user that owns the src node - // may approve an SSH check for that node. Tagged source nodes are - // rejected because they have no user owner to compare against. - if srcNode.IsTagged() || !srcNode.UserID().Valid() { - log.Warn().Caller(). - Str("auth_id", authInfo.AuthID.String()). - Uint64("src_node_id", binding.SrcNodeID.Uint64()). - Bool("src_is_tagged", srcNode.IsTagged()). - Str("oidc_user", user.Username()). - Msg("SSH check rejected: src node has no user owner") - httpUserError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil)) - - return - } - - if srcNode.UserID().Get() != user.ID { - log.Warn().Caller(). - Str("auth_id", authInfo.AuthID.String()). - Uint64("src_node_id", binding.SrcNodeID.Uint64()). - Uint("src_owner_id", srcNode.UserID().Get()). - Uint("oidc_user_id", user.ID). - Str("oidc_user", user.Username()). - Msg("SSH check rejected: OIDC user is not the owner of src node") - httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil)) - - return - } - - // Identity verified — record the verdict for the waiting follow-up. - authReq.FinishAuth(types.AuthVerdict{}) - - content := renderAuthSuccessTemplate(user) - - writer.Header().Set("Content-Type", "text/html; charset=utf-8") - writer.WriteHeader(http.StatusOK) - - if _, err := writer.Write(content.Bytes()); err != nil { //nolint:noinlineerr - util.LogErr(err, "Failed to write HTTP response") - } + a.beginConfirmation(writer, req, authInfo, user, nodeExpiry) } // mergeEmailClaims keeps each verification flag with the address it describes. @@ -763,7 +677,61 @@ func (a *AuthProviderOIDC) setRegisterConfirmCookie( }) } -// beginRegistrationConfirmation captures the resolved OIDC identity and +func (a *AuthProviderOIDC) sshCheckSource( + authID types.AuthID, + authReq *types.AuthRequest, + user *types.User, +) (types.NodeView, error) { + if !authReq.IsSSHCheck() { + log.Warn().Caller(). + Str("auth_id", authID.String()). + Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding") + + return types.NodeView{}, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil) + } + + binding := authReq.SSHCheckBinding() + + srcNode, ok := a.h.state.GetNodeByID(binding.SrcNodeID) + if !ok { + log.Warn().Caller(). + Str("auth_id", authID.String()). + Uint64("src_node_id", binding.SrcNodeID.Uint64()). + Msg("SSH check src node no longer exists") + + return types.NodeView{}, NewHTTPError(http.StatusGone, "src node no longer exists", nil) + } + + // Strict identity binding: only the user that owns the src node + // may approve an SSH check for that node. Tagged source nodes are + // rejected because they have no user owner to compare against. + if srcNode.IsTagged() || !srcNode.UserID().Valid() { + log.Warn().Caller(). + Str("auth_id", authID.String()). + Uint64("src_node_id", binding.SrcNodeID.Uint64()). + Bool("src_is_tagged", srcNode.IsTagged()). + Str("oidc_user", user.Username()). + Msg("SSH check rejected: src node has no user owner") + + return types.NodeView{}, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil) + } + + if srcNode.UserID().Get() != user.ID { + log.Warn().Caller(). + Str("auth_id", authID.String()). + Uint64("src_node_id", binding.SrcNodeID.Uint64()). + Uint("src_owner_id", srcNode.UserID().Get()). + Uint("oidc_user_id", user.ID). + Str("oidc_user", user.Username()). + Msg("SSH check rejected: OIDC user is not the owner of src node") + + return types.NodeView{}, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil) + } + + return srcNode, nil +} + +// beginConfirmation captures the resolved OIDC identity and // node expiry into the cached [types.AuthRequest], sets the CSRF cookie, and // redirects the browser to the confirmation page. // @@ -775,22 +743,23 @@ func (a *AuthProviderOIDC) setRegisterConfirmCookie( // — re-enters the callback with a spent code and paints an error over the // interstitial. Redirecting keeps the code exchange one-shot and makes the // page the user waits on safe to reload. -func (a *AuthProviderOIDC) beginRegistrationConfirmation( +func (a *AuthProviderOIDC) beginConfirmation( writer http.ResponseWriter, req *http.Request, - authID types.AuthID, + authInfo *AuthInfo, user *types.User, nodeExpiry *time.Time, ) { + authID := authInfo.AuthID authReq, ok := a.h.state.GetAuthCacheEntry(authID) if !ok { - log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed") + log.Debug().Caller().Str("auth_id", authID.String()).Msg("auth session expired before authorization completed") httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil)) return } - if !authReq.IsRegistration() { + if authInfo.Registration && !authReq.IsRegistration() { log.Warn().Caller(). Str("auth_id", authID.String()). Msg("OIDC callback hit registration path with auth request that is not a node registration") @@ -799,6 +768,15 @@ func (a *AuthProviderOIDC) beginRegistrationConfirmation( return } + if !authInfo.Registration { + _, err := a.sshCheckSource(authID, authReq, user) + if err != nil { + httpUserError(writer, err) + + return + } + } + csrf := rands.HexString(32) authReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{ @@ -815,9 +793,9 @@ func (a *AuthProviderOIDC) beginRegistrationConfirmation( http.Redirect(writer, req, a.registerConfirmURL(authID), http.StatusSeeOther) } -// RegisterConfirmGetHandler renders the OIDC registration confirmation +// RegisterConfirmGetHandler renders the OIDC registration or SSH confirmation // interstitial. It is reached via the redirect that -// [AuthProviderOIDC.beginRegistrationConfirmation] issues from the OIDC +// [AuthProviderOIDC.beginConfirmation] issues from the OIDC // callback, and it is safe to reload: it only reads the pending // confirmation captured on the cached [types.AuthRequest] and never touches // the one-time code exchange. @@ -873,18 +851,41 @@ func (a *AuthProviderOIDC) RegisterConfirmGetHandler( return } - regData := authReq.RegistrationData() - info := templates.RegisterConfirmInfo{ FormAction: a.registerConfirmURL(authID), CSRFTokenName: registerConfirmCSRFCookie, CSRFToken: pending.CSRF, User: user.Display(), - Hostname: regData.Hostname, - MachineKey: regData.MachineKey.ShortString(), } - if regData.Hostinfo != nil { - info.OS = regData.Hostinfo.OS + + var content string + + if authReq.IsSSHCheck() { + src, err := a.sshCheckSource(authID, authReq, user) + if err != nil { + httpUserError(writer, err) + + return + } + + dst, ok := a.h.state.GetNodeByID(authReq.SSHCheckBinding().DstNodeID) + if !ok { + httpUserError(writer, NewHTTPError(http.StatusGone, "destination node no longer exists", nil)) + + return + } + + content = templates.SSHCheckConfirm(info, src.GivenName(), dst.GivenName()).Render() + } else { + regData := authReq.RegistrationData() + info.Hostname = regData.Hostname + info.MachineKey = regData.MachineKey.ShortString() + + if regData.Hostinfo != nil { + info.OS = regData.Hostinfo.OS + } + + content = templates.RegisterConfirm(info).Render() } // The page carries the token that finalises the registration, so no @@ -893,15 +894,14 @@ func (a *AuthProviderOIDC) RegisterConfirmGetHandler( writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(http.StatusOK) - if _, err := writer.Write([]byte(templates.RegisterConfirm(info).Render())); err != nil { //nolint:noinlineerr + if _, err := writer.Write([]byte(content)); err != nil { //nolint:noinlineerr util.LogErr(err, "Failed to write HTTP response") } } // RegisterConfirmHandler is the POST endpoint behind the OIDC -// registration confirmation interstitial. It validates the CSRF cookie -// against the form-submitted token, finalises the registration via -// [AuthProviderOIDC.handleRegistration], and renders the success page. +// confirmation interstitial. It validates the CSRF cookie against the +// form-submitted token, then finalises registration or SSH approval. func (a *AuthProviderOIDC) RegisterConfirmHandler( writer http.ResponseWriter, req *http.Request, @@ -971,29 +971,44 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler( return } - newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry) - if err != nil { - if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) { - httpUserError(writer, newHTTPUserError( - http.StatusGone, - registrationLinkSpentMsg, - registrationLinkSpentUserMsg, - err, - )) + var content *bytes.Buffer + + if authReq.IsSSHCheck() { + _, err := a.sshCheckSource(authID, authReq, user) + if err != nil { + httpUserError(writer, err) return } - httpUserError(writer, err) + authReq.FinishAuth(types.AuthVerdict{}) - return + content = renderAuthSuccessTemplate(user) + } else { + newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry) + if err != nil { + if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) { + httpUserError(writer, newHTTPUserError( + http.StatusGone, + registrationLinkSpentMsg, + registrationLinkSpentUserMsg, + err, + )) + + return + } + + httpUserError(writer, err) + + return + } + + content = renderRegistrationSuccessTemplate(user, newNode) } - // Clear the CSRF cookie now that the registration is final. + // Clear the CSRF cookie now that the request is final. a.setRegisterConfirmCookie(writer, req, authID, "", -1) - content := renderRegistrationSuccessTemplate(user, newNode) - writer.Header().Set("Content-Type", "text/html; charset=utf-8") writer.WriteHeader(http.StatusOK) diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go index 2cb03cf11..cd2bea408 100644 --- a/hscontrol/oidc_test.go +++ b/hscontrol/oidc_test.go @@ -791,3 +791,163 @@ func TestOIDCCallbackDispatchesUserIdentityChange(t *testing.T) { } }, 3*time.Second, 20*time.Millisecond, "the connected node must receive the updated filter before registration confirmation") } + +func (b *oidcBrowser) pendingSSHCheck(t *testing.T) (types.AuthID, types.Nodes) { + t.Helper() + + provider, ok := b.app.authProvider.(*AuthProviderOIDC) + require.True(t, ok) + + owner, _, err := provider.createOrUpdateUserFromClaim(&types.OIDCClaims{ + Iss: b.idp.Issuer(), Sub: "ssh-owner", Username: "ssh-owner", + }) + require.NoError(t, err) + + nodes := make(types.Nodes, 0, 2) + + for _, name := range []string{"ssh-source", "ssh-destination"} { + node := b.app.state.CreateRegisteredNodeForTest(owner, name) + node.User = owner.Clone() + b.app.state.PutNodeInStoreForTest(*node) + nodes = append(nodes, node) + } + + authID := types.MustAuthID() + b.app.state.SetAuthCacheEntry(authID, types.NewSSHCheckAuthRequest(nodes[0].ID, nodes[1].ID)) + + return authID, nodes +} + +func TestSSHCheckNeedsConfirmation(t *testing.T) { + for _, change := range []string{"approve", "reowned", "tagged"} { + t.Run(change, func(t *testing.T) { + b := newOIDCBrowser(t) + authID, nodes := b.pendingSSHCheck(t) + src, dst := nodes[0], nodes[1] + auth, ok := b.app.state.GetAuthCacheEntry(authID) + require.True(t, ok) + b.idp.QueueUser(&mockoidc.MockUser{ + Subject: src.User.Name, PreferredUsername: src.User.Name, + }) + + status, landed, body := b.get(t, b.publicURL+"/auth/"+authID.String()) + require.Equal(t, http.StatusOK, status) + + select { + case <-auth.WaitForAuth(): + t.Fatal("opening the check link must not approve the session") + default: + } + + require.Equal(t, "/register/confirm/"+authID.String(), landed.Path) + require.Contains(t, body, "Confirm SSH session") + require.Contains(t, body, src.GivenName) + require.Contains(t, body, dst.GivenName) + require.Contains(t, body, "Approved by") + require.Contains(t, body, src.User.Display()) + require.Contains(t, body, "check period") + + csrf := csrfInputRe.FindStringSubmatch(body) + require.Len(t, csrf, 2) + + form := url.Values{registerConfirmCSRFCookie: {csrf[1]}} + withoutCookie := &http.Client{} + //nolint:noctx,bodyclose // test response closed below + refused, err := withoutCookie.PostForm(landed.String(), form) + require.NoError(t, err) + refused.Body.Close() + require.Equal(t, http.StatusForbidden, refused.StatusCode) + + select { + case <-auth.WaitForAuth(): + t.Fatal("a POST without the callback cookie must leave the check pending") + default: + } + + switch change { + case "reowned": + other := b.app.state.CreateUserForTest("other-owner") + updated := src.Clone() + updated.UserID, updated.User = new(other.ID), other.Clone() + b.app.state.PutNodeInStoreForTest(*updated) + case "tagged": + updated := src.Clone() + updated.Tags = []string{"tag:changed"} + b.app.state.PutNodeInStoreForTest(*updated) + } + + //nolint:noctx,bodyclose // test response closed below + confirmed, err := b.client.PostForm(landed.String(), form) + require.NoError(t, err) + + confirmedBody, err := io.ReadAll(confirmed.Body) + confirmed.Body.Close() + require.NoError(t, err) + + if change != "approve" { + require.Equal(t, http.StatusForbidden, confirmed.StatusCode) + + select { + case <-auth.WaitForAuth(): + t.Fatal("a changed source owner must leave the check pending") + default: + } + + status, _, _ = b.get(t, landed.String()) + require.Equal(t, http.StatusForbidden, status) + + return + } + + require.Equal(t, http.StatusOK, confirmed.StatusCode) + require.Contains(t, string(confirmedBody), "SSH session authorized") + + select { + case verdict := <-auth.WaitForAuth(): + require.True(t, verdict.Accept()) + default: + t.Fatal("confirmation must approve the SSH check") + } + }) + } +} + +func TestSSHCheckRejectsOtherUser(t *testing.T) { + b := newOIDCBrowser(t) + authID, _ := b.pendingSSHCheck(t) + auth, ok := b.app.state.GetAuthCacheEntry(authID) + require.True(t, ok) + b.idp.QueueUser(&mockoidc.MockUser{Subject: "other-ssh-user", PreferredUsername: "other"}) + + status, _, _ := b.get(t, b.publicURL+"/auth/"+authID.String()) + require.Equal(t, http.StatusForbidden, status) + require.Nil(t, auth.PendingConfirmation()) + + select { + case <-auth.WaitForAuth(): + t.Fatal("a different user must not approve the SSH check") + default: + } +} + +func TestOIDCConfirmationRejectsWrongRequestKind(t *testing.T) { + for _, path := range []string{"auth", "register"} { + t.Run(path, func(t *testing.T) { + b := newOIDCBrowser(t) + + var authID types.AuthID + if path == "auth" { + authID, _ = b.pendingNode(t) + } else { + authID, _ = b.pendingSSHCheck(t) + } + + status, _, _ := b.get(t, b.publicURL+"/"+path+"/"+authID.String()) + require.Equal(t, http.StatusBadRequest, status) + + auth, ok := b.app.state.GetAuthCacheEntry(authID) + require.True(t, ok) + require.Nil(t, auth.PendingConfirmation()) + }) + } +} diff --git a/hscontrol/templates/register_confirm.go b/hscontrol/templates/register_confirm.go index a7297017c..8962cd239 100644 --- a/hscontrol/templates/register_confirm.go +++ b/hscontrol/templates/register_confirm.go @@ -15,6 +15,7 @@ import ( // detail (hostname, OS, machine-key fingerprint) for the user to // recognise whether the device they are about to claim is in fact // theirs. +// The form and user fields are also used for SSH check confirmations. type RegisterConfirmInfo struct { // FormAction is the absolute or relative URL the confirm form // POSTs to. Typically /register/confirm/{auth_id}. @@ -28,8 +29,7 @@ type RegisterConfirmInfo struct { // the cookie set by the OIDC callback before the POST is honoured. CSRFToken string - // User is the OIDC-authenticated identity the device will be - // registered to if the user confirms. + // User is the OIDC-authenticated identity approving the request. User string // Hostname is the hostname the registering tailscaled instance @@ -61,7 +61,53 @@ func RegisterConfirm(info RegisterConfirmInfo) *elem.Element { }, ) - form := elem.Form( + return page( + "Headscale - Confirm node registration", + H2(elem.Text("Confirm node registration")), + P(elem.Text( + "A device is asking to be added to your tailnet. "+ + "Please review the details below and confirm that this device is yours.", + )), + deviceList, + confirmForm(info, "Confirm registration"), + P(elem.Text( + "If you do not recognise this device, close this window. "+ + "The registration request will expire automatically.", + )), + ) +} + +// SSHCheckConfirm asks the owner to approve the source and destination node pair. +func SSHCheckConfirm( + info RegisterConfirmInfo, + source, destination string, +) *elem.Element { + return page( + "Headscale - Confirm SSH session", + H2(elem.Text("Confirm SSH session")), + P(elem.Text( + "An SSH session is waiting for your approval. "+ + "Review the source and destination nodes before continuing.", + )), + deviceTable([]deviceRow{ + {"From", elem.Text(source)}, + {"To", elem.Text(destination)}, + {"Approved by", elem.Text(info.User)}, + }), + P(elem.Text( + "Approval also allows new SSH sessions between these two nodes "+ + "without asking again until the check period ends.", + )), + confirmForm(info, "Approve SSH session"), + P(elem.Text( + "If you do not recognise this request, close this window. "+ + "The request will expire automatically.", + )), + ) +} + +func confirmForm(info RegisterConfirmInfo, buttonText string) *elem.Element { + return elem.Form( attrs.Props{ attrs.Method: "POST", attrs.Action: info.FormAction, @@ -73,24 +119,9 @@ func RegisterConfirm(info RegisterConfirmInfo) *elem.Element { }), elem.Button( attrs.Props{attrs.Type: "submit"}, - elem.Text("Confirm registration"), + elem.Text(buttonText), ), ) - - return page( - "Headscale - Confirm node registration", - H2(elem.Text("Confirm node registration")), - P(elem.Text( - "A device is asking to be added to your tailnet. "+ - "Please review the details below and confirm that this device is yours.", - )), - deviceList, - form, - P(elem.Text( - "If you do not recognise this device, close this window. "+ - "The registration request will expire automatically.", - )), - ) } type deviceRow struct { diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index 05edd706f..7a81a0554 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -92,11 +92,10 @@ type SSHCheckBinding struct { } // PendingRegistrationConfirmation captures the server-side state needed -// to finalise a node registration after the user has confirmed it on -// the OIDC interstitial. The OIDC callback resolves the user identity -// and node expiry, stores them on the cached [AuthRequest], and renders -// a confirmation page; only when the user POSTs the confirmation form -// does the actual node registration run. +// to finalise a node registration or SSH check after the user confirms +// it on the OIDC interstitial. The callback resolves the user identity +// and optional node expiry, then stores them on the cached [AuthRequest]. +// Only the confirmation POST registers the node or approves the SSH check. // // CSRF is a one-shot per-session token that the OIDC callback set // both as a cookie and as a hidden form field. The confirm POST @@ -127,7 +126,7 @@ type AuthRequest struct { // sshBinding is populated for SSH check-mode flows. It captures the // (src, dst) node pair the request was minted for so the follow-up - // and OIDC callback can refuse to record a verdict for any other + // and OIDC confirmation handler can refuse a verdict for any other // pair. // // nil for non-SSH-check flows. Use [AuthRequest.SSHCheckBinding] to read it @@ -135,10 +134,10 @@ type AuthRequest struct { sshBinding *SSHCheckBinding // pendingConfirmation is populated by the OIDC callback for the - // node-registration flow once the user identity has been resolved - // but before the user has explicitly confirmed the registration on + // registration or SSH check once the user identity has been resolved + // but before the user has explicitly confirmed the request on // the interstitial. The /register/confirm POST handler reads it to - // finalise the registration without re-running the OIDC flow. + // finalise the request without re-running the OIDC flow. pendingConfirmation *PendingRegistrationConfirmation finished chan AuthVerdict @@ -171,7 +170,7 @@ func NewRegisterAuthRequest(data *RegistrationData) *AuthRequest { // NewSSHCheckAuthRequest creates a pending auth request bound to a // specific (src, dst) SSH check-mode pair. The follow-up handler and -// OIDC callback must verify their incoming request matches this binding +// OIDC confirmation handler must verify the request matches this binding // before recording any verdict. func NewSSHCheckAuthRequest(src, dst NodeID) *AuthRequest { return &AuthRequest{ @@ -220,15 +219,14 @@ func (rn *AuthRequest) IsSSHCheck() bool { } // SetPendingConfirmation marks this [AuthRequest] as having an -// OIDC-resolved user that is waiting to confirm the registration on -// the interstitial. The OIDC callback should call this and then render -// the confirmation page; the /register/confirm POST handler reads the -// stored UserID/NodeExpiry to finish the registration. +// OIDC-resolved user waiting to confirm registration or an SSH check. +// The callback stores this before redirecting to the confirmation page; +// the /register/confirm POST handler reads it to finish the request. func (rn *AuthRequest) SetPendingConfirmation(p *PendingRegistrationConfirmation) { rn.pendingConfirmation = p } -// PendingConfirmation returns the pending OIDC-resolved registration +// PendingConfirmation returns the pending OIDC-resolved confirmation // state captured by [AuthRequest.SetPendingConfirmation], or nil if no OIDC callback // has yet resolved an identity for this [AuthRequest]. func (rn *AuthRequest) PendingConfirmation() *PendingRegistrationConfirmation {