Live video on the AT Protocol
1package oproxy
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "time"
8
9 oauth "github.com/haileyok/atproto-oauth-golang"
10 "github.com/lestrrat-go/jwx/v2/jwk"
11)
12
13var refreshWhenRemaining = time.Minute * 15
14
15// OAuthSession stores authentication data needed during the OAuth flow
16type OAuthSession struct {
17 DID string `json:"did" gorm:"column:repo_did;index"`
18 Handle string `json:"handle" gorm:"column:handle;index"` // possibly also did if they have no handle
19 PDSUrl string `json:"pds_url" gorm:"column:pds_url;index"`
20
21 // Upstream fields
22 UpstreamState string `json:"upstream_state" gorm:"column:upstream_state;index"`
23 UpstreamAuthServerIssuer string `json:"upstream_auth_server_issuer" gorm:"column:upstream_auth_server_issuer"`
24 UpstreamPKCEVerifier string `json:"upstream_pkce_verifier" gorm:"column:upstream_pkce_verifier"`
25 UpstreamDPoPNonce string `json:"upstream_dpop_nonce" gorm:"column:upstream_dpop_nonce"`
26 UpstreamDPoPPrivateJWK string `json:"upstream_dpop_private_jwk" gorm:"column:upstream_dpop_private_jwk;type:text"`
27 UpstreamAccessToken string `json:"upstream_access_token" gorm:"column:upstream_access_token"`
28 UpstreamAccessTokenExp *time.Time `json:"upstream_access_token_exp" gorm:"column:upstream_access_token_exp"`
29 UpstreamRefreshToken string `json:"upstream_refresh_token" gorm:"column:upstream_refresh_token"`
30
31 // Downstream fields
32 DownstreamDPoPNonce string `json:"downstream_dpop_nonce" gorm:"column:downstream_dpop_nonce"`
33 DownstreamDPoPJKT string `json:"downstream_dpop_jkt" gorm:"column:downstream_dpop_jkt;primaryKey"`
34 DownstreamAccessToken string `json:"downstream_access_token" gorm:"column:downstream_access_token;index"`
35 DownstreamRefreshToken string `json:"downstream_refresh_token" gorm:"column:downstream_refresh_token;index"`
36 DownstreamAuthorizationCode string `json:"downstream_authorization_code" gorm:"column:downstream_authorization_code;index"`
37 DownstreamState string `json:"downstream_state" gorm:"column:downstream_state"`
38 DownstreamScope string `json:"downstream_scope" gorm:"column:downstream_scope"`
39 DownstreamCodeChallenge string `json:"downstream_code_challenge" gorm:"column:downstream_code_challenge"`
40 DownstreamPARRequestURI string `json:"downstream_par_request_uri" gorm:"column:downstream_par_request_uri"`
41 DownstreamPARUsedAt *time.Time `json:"downstream_par_used_at" gorm:"column:downstream_par_used_at"`
42 DownstreamRedirectURI string `json:"downstream_redirect_uri" gorm:"column:downstream_redirect_uri"`
43
44 RevokedAt *time.Time `json:"revoked_at" gorm:"column:revoked_at"`
45 CreatedAt time.Time `json:"created_at"`
46 UpdatedAt time.Time `json:"updated_at"`
47}
48
49// for gorm. this is prettier than "o_auth_sessions"
50func (o *OAuthSession) TableName() string {
51 return "oauth_sessions"
52}
53
54type OAuthSessionStatus string
55
56const (
57 // We've gotten the first request and sent it back for a new nonce
58 OAuthSessionStatePARPending OAuthSessionStatus = "par-pending"
59 // PAR has been created, but not yet used
60 OAuthSessionStatePARCreated OAuthSessionStatus = "par-created"
61 // PAR has been used, but maybe upstream will fail for some reason
62 OAuthSessionStatePARUsed OAuthSessionStatus = "par-used"
63 // PAR has been used, we're waiting to hear back from upstream
64 OAuthSessionStateUpstream OAuthSessionStatus = "upstream"
65 // Upstream came back, we've issued the user a code but it hasn't been used yet
66 OAuthSessionStateDownstream OAuthSessionStatus = "downstream"
67 // Code has been used, everything is good
68 OAuthSessionStateReady OAuthSessionStatus = "ready"
69 // For any reason we're done. Revoked or expired
70 OAuthSessionStateRejected OAuthSessionStatus = "rejected"
71)
72
73func (o *OAuthSession) Status() OAuthSessionStatus {
74 if o.RevokedAt != nil {
75 return OAuthSessionStateRejected
76 }
77 if o.DownstreamAccessToken != "" {
78 return OAuthSessionStateReady
79 }
80 if o.DownstreamAuthorizationCode != "" {
81 return OAuthSessionStateDownstream
82 }
83 if o.UpstreamDPoPPrivateJWK != "" {
84 return OAuthSessionStateUpstream
85 }
86 if o.DownstreamPARUsedAt != nil {
87 return OAuthSessionStatePARUsed
88 }
89 if o.DownstreamPARRequestURI != "" {
90 return OAuthSessionStatePARCreated
91 }
92 if o.DownstreamDPoPNonce != "" {
93 return OAuthSessionStatePARPending
94 }
95 bs, _ := json.Marshal(o)
96 fmt.Printf("unknown oauth session status: %s\n", string(bs))
97 // todo: this should never happen, log a warning? panic?
98 return OAuthSessionStateRejected
99}
100
101func (o *OProxy) loadOAuthSession(jkt string) (*OAuthSession, error) {
102 session, err := o.userLoadOAuthSession(jkt)
103 if err != nil {
104 return nil, err
105 }
106 if session == nil {
107 return nil, nil
108 }
109 if session.Status() != OAuthSessionStateReady {
110 return session, nil
111 }
112 if session.UpstreamAccessTokenExp.Sub(time.Now()) > refreshWhenRemaining {
113 return session, nil
114 }
115
116 upstreamMeta := o.GetUpstreamMetadata()
117
118 oclient, err := oauth.NewClient(oauth.ClientArgs{
119 ClientJwk: o.upstreamJWK,
120 ClientId: upstreamMeta.ClientID,
121 RedirectUri: upstreamMeta.RedirectURIs[0],
122 })
123
124 dpopKey, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK))
125 if err != nil {
126 return nil, fmt.Errorf("failed to parse upstream dpop private key: %w", err)
127 }
128
129 // refresh upstream before returning
130 resp, err := oclient.RefreshTokenRequest(context.Background(), session.UpstreamRefreshToken, session.UpstreamAuthServerIssuer, session.UpstreamDPoPNonce, dpopKey)
131 if err != nil {
132 // revoke, probably
133 o.slog.Error("failed to refresh upstream token, revoking downstream session", "error", err)
134 now := time.Now()
135 session.RevokedAt = &now
136 err = o.updateOAuthSession(session.DownstreamDPoPJKT, session)
137 if err != nil {
138 o.slog.Error("after upstream token refresh, failed to revoke downstream session", "error", err)
139 }
140 return nil, fmt.Errorf("failed to refresh upstream token: %w", err)
141 }
142
143 exp := time.Now().Add(time.Second * time.Duration(resp.ExpiresIn)).UTC()
144 session.UpstreamAccessToken = resp.AccessToken
145 session.UpstreamAccessTokenExp = &exp
146 session.UpstreamRefreshToken = resp.RefreshToken
147
148 err = o.updateOAuthSession(session.DownstreamDPoPJKT, session)
149 if err != nil {
150 return nil, fmt.Errorf("failed to update downstream session after upstream token refresh: %w", err)
151 }
152
153 o.slog.Debug("refreshed upstream token", "session", session.DownstreamDPoPJKT)
154
155 return session, nil
156}