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.
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:37 +00:00
1 parent 4563650475
commit 1f1e75c56f
4 files changed
+373 -154

No files matched your search

+132 -117
View File
@@ -391,93 +391,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
a.h.Change(userChange)
// 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.AuthID, authInfo.Registration, user, nodeExpiry)
}
// mergeEmailClaims keeps each verification flag with the address it describes.
@@ -823,7 +737,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.
//
@@ -835,22 +803,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,
registration bool,
user *types.User,
nodeExpiry *time.Time,
) {
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 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")
@@ -859,6 +828,15 @@ func (a *AuthProviderOIDC) beginRegistrationConfirmation(
return
}
if !registration {
_, err := a.sshCheckSource(authID, authReq, user)
if err != nil {
httpUserError(writer, err)
return
}
}
csrf := rands.HexString(32)
authReq.SetPendingConfirmation(&types.PendingRegistrationConfirmation{
@@ -875,9 +853,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.
@@ -933,18 +911,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
@@ -953,15 +954,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,
@@ -1031,29 +1031,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)
+178 -3
View File
@@ -938,15 +938,30 @@ func TestOIDCPendingCallbackPreservesSSHVerdict(t *testing.T) {
src := b.app.state.CreateNodeForTest(user, "oidc-ssh-source")
b.app.state.PutNodeInStoreForTest(*src)
dst := b.app.state.CreateNodeForTest(user, "oidc-ssh-destination")
b.app.state.PutNodeInStoreForTest(*dst)
authID := types.MustAuthID()
authReq := types.NewSSHCheckAuthRequest(src.ID, 2)
authReq := types.NewSSHCheckAuthRequest(src.ID, dst.ID)
require.NoError(t, b.app.state.SetAuthCacheEntry(authID, authReq))
status, _, body := b.get(t, a.AuthURL(authID))
status, landed, body := b.get(t, a.AuthURL(authID))
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "You may return to your terminal.")
assert.Zero(t, a.authCache.Len())
csrf := csrfInputRe.FindStringSubmatch(body)
require.Len(t, csrf, 2)
//nolint:noctx,bodyclose // test response closed below
confirmed, err := b.client.PostForm(landed.String(), url.Values{registerConfirmCSRFCookie: {csrf[1]}})
require.NoError(t, err)
confirmedBody, err := io.ReadAll(confirmed.Body)
confirmed.Body.Close()
require.NoError(t, err)
require.Equal(t, http.StatusOK, confirmed.StatusCode)
assert.Contains(t, string(confirmedBody), "You may return to your terminal.")
cached, ok := b.app.state.GetAuthCacheEntry(authID)
require.True(t, ok, "completing OIDC must preserve the SSH verdict for its polling client")
assert.Same(t, authReq, cached)
@@ -1548,6 +1563,166 @@ 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()
require.NoError(t, 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())
})
}
}
func TestOIDCRegistrationRejectsOversizedMergedHostinfo(t *testing.T) {
t.Parallel()
+50 -19
View File
@@ -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 {
+13 -15
View File
@@ -94,11 +94,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
@@ -129,7 +128,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
@@ -137,10 +136,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
@@ -199,7 +198,7 @@ func (rn *AuthRequest) PrepareForCache() error {
// 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{
@@ -265,15 +264,14 @@ func (rn *AuthRequest) IsFinished() 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 {