• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pomerium / pomerium / 19183140028

07 Nov 2025 10:38PM UTC coverage: 55.476% (-0.6%) from 56.094%
19183140028

push

github

web-flow
feat: ssh authorization code flow (#5873)

## Summary

Implements the oauth authorization code flow for native SSH connections
in pomerium.

## Related issues


[ENG-3056](https://linear.app/pomerium/issue/ENG-3056/ssh-authorization-code-flow-initial-implementation)

The issue has a comment explaining some known limitations.

## User Explanation

Allows user to use native SSH without requiring IDPs to support the
device code flow. Native ssh now uses the Authorization code flow, which
most if not all IDPs support - notably, any existing IDP configuration
for non-native ssh Pomerium should now work with native ssh.

## Checklist

- [X] reference any related issues
- [X] updated unit tests
- [X] add appropriate label (`enhancement`, `bug`, `breaking`,
`dependencies`, `ci`)
- [X] ready for review

---------

Co-authored-by: Joe Kralicky <joekralicky@gmail.com>

284 of 1065 new or added lines in 19 files covered. (26.67%)

25 existing lines in 5 files now uncovered.

28737 of 51801 relevant lines covered (55.48%)

94.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/pkg/ssh/code/relational.go
1
package code
2

3
import (
4
        "context"
5
        "slices"
6
        "time"
7

8
        "github.com/rs/zerolog/log"
9
        "google.golang.org/grpc/codes"
10
        "google.golang.org/grpc/status"
11
        "google.golang.org/protobuf/types/known/structpb"
12

13
        "github.com/pomerium/pomerium/pkg/grpc/databroker"
14
        "github.com/pomerium/pomerium/pkg/grpc/session"
15
)
16

17
type Tuple2[A, B any] struct {
18
        A A
19
        B B
20
}
21

NEW
22
func T2[A, B any](a A, b B) Tuple2[A, B] {
×
NEW
23
        return Tuple2[A, B]{A: a, B: b}
×
NEW
24
}
×
25

NEW
26
func indexedFieldFilter(field, value string) (filter *structpb.Struct) {
×
NEW
27
        filter, _ = structpb.NewStruct(map[string]any{
×
NEW
28
                field: map[string]any{
×
NEW
29
                        "$eq": value,
×
NEW
30
                },
×
NEW
31
        })
×
NEW
32
        return
×
NEW
33
}
×
34

NEW
35
func getSbrByFingerprintBuilder(fingerprintID string) *databroker.QueryRequest {
×
NEW
36
        filter := indexedFieldFilter("key", fingerprintID)
×
NEW
37
        return &databroker.QueryRequest{
×
NEW
38
                Type:   "type.googleapis.com/session.SessionBindingRequest",
×
NEW
39
                Filter: filter,
×
NEW
40
                Limit:  queryLimit,
×
NEW
41
        }
×
NEW
42
}
×
43

NEW
44
func getSbBySessionBuilder(sessionID string) *databroker.QueryRequest {
×
NEW
45
        filter := indexedFieldFilter("session_id", sessionID)
×
NEW
46
        return &databroker.QueryRequest{
×
NEW
47
                Type:   "type.googleapis.com/session.SessionBinding",
×
NEW
48
                Filter: filter,
×
NEW
49
                Limit:  queryLimit,
×
NEW
50
        }
×
NEW
51
}
×
52

53
func getCodeByBindingKey(
54
        ctx context.Context,
55
        client databroker.DataBrokerServiceClient,
56
        fingerprintID string,
NEW
57
) (CodeID, error) {
×
NEW
58
        now := time.Now()
×
NEW
59
        qr, err := client.Query(ctx, getSbrByFingerprintBuilder(fingerprintID))
×
NEW
60
        if err != nil {
×
NEW
61
                return "", err
×
NEW
62
        }
×
NEW
63
        if len(qr.GetRecords()) == 0 {
×
NEW
64
                return "", nil
×
NEW
65
        }
×
NEW
66
        ret := make([]Tuple2[CodeID, *session.SessionBindingRequest], 0, len(qr.Records))
×
NEW
67
        for _, rec := range qr.GetRecords() {
×
NEW
68
                if rec.GetDeletedAt() != nil {
×
NEW
69
                        continue
×
70
                }
NEW
71
                s := &session.SessionBindingRequest{}
×
NEW
72
                if err := rec.GetData().UnmarshalTo(s); err != nil {
×
NEW
73
                        log.Err(err).Ctx(ctx).Msg("getCodeByBindingKey : failed to unmarshal session binding request")
×
NEW
74
                        continue
×
75
                }
NEW
76
                if s.ExpiresAt.AsTime().Before(now) {
×
NEW
77
                        continue
×
78
                }
NEW
79
                if s.State != session.SessionBindingRequestState_InFlight {
×
NEW
80
                        // already processed
×
NEW
81
                        continue
×
82
                }
NEW
83
                ret = append(ret, T2(CodeID(rec.GetId()), s))
×
84
        }
85

NEW
86
        slices.SortFunc(ret, func(a, b Tuple2[CodeID, *session.SessionBindingRequest]) int {
×
NEW
87
                return a.B.GetCreatedAt().AsTime().Compare(b.B.CreatedAt.AsTime())
×
NEW
88
        })
×
89

NEW
90
        if len(ret) == 0 {
×
NEW
91
                return "", status.Error(codes.NotFound, "no valid codes")
×
NEW
92
        }
×
93

NEW
94
        n := len(ret) - 1
×
NEW
95
        return ret[n].A, nil
×
96
}
97

NEW
98
func getSessionBindingBySession(ctx context.Context, client databroker.DataBrokerServiceClient, sessionID string) ([]*databroker.Record, error) {
×
NEW
99
        qr, err := client.Query(ctx, getSbBySessionBuilder(sessionID))
×
NEW
100
        if err != nil {
×
NEW
101
                return nil, err
×
NEW
102
        }
×
NEW
103
        return qr.GetRecords(), nil
×
104
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc