oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package connect
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "errors"
8 "net/http"
9 "strings"
10 "time"
11
12 "amadan.net/oficina/home/connect/linkstore"
13 "amadan.net/oficina/home/handoff"
14 "amadan.net/oficina/home/handoff/seal"
15 "amadan.net/rastrillo/rastrillo/assertion"
16)
17
18const homeInitCookie = "__Host-oficina-home-init"
19const homeProofCookie = "__Host-oficina-home-proof"
20
21var errHomeBridge = errors.New("home connect: exchange unavailable")
22var errIdentityRefused = errors.New("home connect: identity refused")
23
24type Bridge struct {
25 origin, issuer, sessionCookie string
26 namespace string
27 keyHash [32]byte
28 links *linkstore.Store
29 ordinary, fresh *handoff.Consumer
30 now func() time.Time
31 returnPath func(*http.Request) string
32 readSession func(*http.Request) (*Session, error)
33 mintSession func(http.ResponseWriter, *http.Request, Session) error
34 localPerson func(context.Context, Session) (Person, error)
35 render func(http.ResponseWriter, *http.Request, string, string, int)
36}
37type homeFinishKey struct{}
38type homeFinishResult struct {
39 session *Session
40 init string
41 state string
42 status int
43}
44
45// Both subsystem schemas must be migrated before construction. The consumers
46// share one replay store; choosing a different Finish cannot weaken freshness.
47func New(c Config) (*Bridge, error) {
48 if c.DB == nil || c.ReadSession == nil || c.MintHomeSession == nil || c.LookupLocalSession == nil || c.LookupPerson == nil || c.Render == nil || !validSessionCookie(c.SessionCookie) {
49 return nil, errHomeBridge
50 }
51 sealer, err := seal.New(c.DEK, c.Namespace)
52 if err != nil {
53 return nil, errHomeBridge
54 }
55 exchange, err := handoff.NewStore(c.DB, sealer, c.Issuer, c.Origin, c.Keys)
56 if err != nil {
57 return nil, errHomeBridge
58 }
59 linker, err := linkstore.New(c.DB, c.DEK, c.Origin, c.Issuer, c.Namespace, c.LookupPerson)
60 if err != nil {
61 return nil, errHomeBridge
62 }
63 now := c.Now
64 if now == nil {
65 now = time.Now
66 }
67 b := &Bridge{namespace: c.Namespace, keyHash: sha256.Sum256(c.DEK), origin: c.Origin, issuer: c.Issuer, sessionCookie: c.SessionCookie, links: linker, now: now, returnPath: c.ReturnPath, readSession: c.ReadSession, mintSession: c.MintHomeSession, localPerson: c.LookupLocalSession, render: c.Render}
68 cfg := handoff.ConsumerConfig{Store: exchange, ConsumerID: c.ConsumerID, Complete: b.complete, Fail: b.fail, Now: func() time.Time { return b.now() }, CurrentSubject: func(r *http.Request) string {
69 result, _ := r.Context().Value(homeFinishKey{}).(*homeFinishResult)
70 if result != nil && result.session != nil && result.session.Method == HomeMethod {
71 return result.session.Subject
72 }
73 return ""
74 }}
75 b.ordinary, err = handoff.NewConsumer(cfg)
76 if err != nil {
77 return nil, errHomeBridge
78 }
79 cfg.RequireFreshAuth = true
80 cfg.BindStart = func(r *http.Request, nonce string) error {
81 secret, present, ok := homeCookie(r, homeInitCookie)
82 if !present || !ok {
83 return errIdentityRefused
84 }
85 local, _, err := b.localBinding(r)
86 if err != nil {
87 return err
88 }
89 return b.links.BindNonce(r.Context(), secret, local, nonce, b.now())
90 }
91 b.fresh, err = handoff.NewConsumer(cfg)
92 if err != nil {
93 return nil, errHomeBridge
94 }
95 if exchange.Sweep(context.Background(), b.now()) != nil || linker.Sweep(context.Background(), b.now()) != nil {
96 return nil, errHomeBridge
97 }
98 return b, nil
99}
100
101// homeCookie distinguishes absent cookies from malformed or duplicate values.
102// Neither a duplicate nor an invalid link cookie may select ordinary sign-in.
103func homeCookie(r *http.Request, name string) (value string, present, valid bool) {
104 count := 0
105 for _, c := range r.Cookies() {
106 if c.Name == name {
107 count++
108 value = c.Value
109 }
110 }
111 if count == 0 {
112 return "", false, true
113 }
114 if count != 1 || len(value) != 43 {
115 return "", true, false
116 }
117 raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
118 return value, true, err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == value
119}
120func setHomeCookie(w http.ResponseWriter, name, value string, seconds int) {
121 http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
122}
123func (b *Bridge) actualSession(r *http.Request) (*Session, error) {
124 _, present, ok := homeCookie(r, b.sessionCookie)
125 if !ok {
126 return nil, errIdentityRefused
127 }
128 if !present {
129 return nil, nil
130 }
131 session, err := b.readSession(r)
132 if err != nil || session == nil {
133 return nil, errIdentityRefused
134 }
135 return session, nil
136}
137func (b *Bridge) Start(w http.ResponseWriter, r *http.Request) {
138 _, present, ok := homeCookie(r, homeInitCookie)
139 if !ok {
140 b.fail(w, r, http.StatusBadRequest)
141 return
142 }
143 // Duplicate auth cookies must fail before a fresh hook or future mint.
144 if _, err := b.actualSession(r); err != nil {
145 b.fail(w, r, http.StatusForbidden)
146 return
147 }
148 if present {
149 b.fresh.Start(w, r)
150 } else {
151 b.ordinary.Start(w, r)
152 }
153}
154func (b *Bridge) Callback(w http.ResponseWriter, r *http.Request) { b.ordinary.Callback(w, r) }
155func (b *Bridge) Finish(w http.ResponseWriter, r *http.Request) {
156 init, _, ok := homeCookie(r, homeInitCookie)
157 if !ok {
158 b.fail(w, r, http.StatusBadRequest)
159 return
160 }
161 _, _, ok = homeCookie(r, homeProofCookie)
162 if !ok {
163 b.fail(w, r, http.StatusBadRequest)
164 return
165 }
166 session, err := b.actualSession(r)
167 if err != nil {
168 b.fail(w, r, http.StatusForbidden)
169 return
170 }
171 result := &homeFinishResult{session: session, init: init}
172 b.ordinary.Finish(w, r.WithContext(context.WithValue(r.Context(), homeFinishKey{}, result)))
173}
174func (b *Bridge) fail(w http.ResponseWriter, r *http.Request, status int) {
175 state := "failed"
176 if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok && result.state != "" {
177 state, status = result.state, result.status
178 }
179 b.render(w, r, state, "", status)
180}
181func rejectHome(r *http.Request, state string, status int) error {
182 if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok {
183 result.state, result.status = state, status
184 }
185 return errIdentityRefused
186}
187func (b *Bridge) complete(w http.ResponseWriter, r *http.Request, claims assertion.Claims) error {
188 result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult)
189 if !ok {
190 return errHomeBridge
191 }
192 if result.init != "" {
193 setHomeCookie(w, homeInitCookie, "", -1)
194 local, _, err := b.localBinding(r)
195 if err != nil {
196 return rejectHome(r, "failed", http.StatusForbidden)
197 }
198 secret, err := b.links.Complete(r.Context(), result.init, local, linkstore.Home{Issuer: claims.Issuer, Subject: claims.Subject, Nonce: claims.Nonce, AuthTime: time.Unix(claims.AuthTime, 0)}, b.now())
199 if errors.Is(err, linkstore.ErrRefused) {
200 return rejectHome(r, "failed", http.StatusForbidden)
201 }
202 if err != nil {
203 return errHomeBridge
204 }
205 setHomeCookie(w, homeProofCookie, secret, 300)
206 return nil
207 }
208 id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject)
209 if errors.Is(err, linkstore.ErrRefused) {
210 return rejectHome(r, "unknown", http.StatusForbidden)
211 }
212 if err != nil {
213 return errHomeBridge
214 }
215 if result.session != nil {
216 person, err := b.existingPerson(r, *result.session)
217 if err != nil || person.ID != id {
218 return rejectHome(r, "failed", http.StatusForbidden)
219 }
220 }
221 if err := b.mintSession(w, r, Session{Subject: claims.Subject, Method: HomeMethod, AuthTime: time.Unix(claims.AuthTime, 0)}); err != nil {
222 return errHomeBridge
223 }
224 return nil
225}
226
227// existingPerson never creates a person or samples, even during ordinary SSO.
228func (b *Bridge) existingPerson(r *http.Request, session Session) (Person, error) {
229 if session.Method == HomeMethod {
230 return b.Resolve(r.Context(), session.Subject)
231 }
232 if session.Method != "magiclink" && session.Method != "keymail" {
233 return Person{}, errIdentityRefused
234 }
235 p, err := b.localPerson(r.Context(), session)
236 if err != nil || p.ID <= 0 || p.Label == "" {
237 return Person{}, errIdentityRefused
238 }
239 return p, nil
240}
241func (b *Bridge) localBinding(r *http.Request) (linkstore.Local, Person, error) {
242 session, err := b.actualSession(r)
243 if err != nil || session == nil {
244 return linkstore.Local{}, Person{}, errIdentityRefused
245 }
246 at, fresh := FreshLocal(*session, b.now())
247 if !fresh {
248 return linkstore.Local{}, Person{}, errIdentityRefused
249 }
250 person, err := b.existingPerson(r, *session)
251 if err != nil {
252 return linkstore.Local{}, Person{}, err
253 }
254 token, _, _ := homeCookie(r, b.sessionCookie)
255 return linkstore.Local{PersonID: person.ID, SessionToken: token, AuthTime: at}, person, nil
256}
257func (b *Bridge) guard(w http.ResponseWriter, r *http.Request, method, path string) bool {
258 w.Header().Set("Cache-Control", "no-store")
259 if r.Host != strings.TrimPrefix(b.origin, "https://") || r.URL.Path != path {
260 b.fail(w, r, http.StatusNotFound)
261 return false
262 }
263 if r.Method != method {
264 w.Header().Set("Allow", method)
265 b.fail(w, r, http.StatusMethodNotAllowed)
266 return false
267 }
268 if r.URL.RawQuery != "" {
269 b.fail(w, r, http.StatusBadRequest)
270 return false
271 }
272 return true
273}
274
275func validSessionCookie(name string) bool {
276 if !strings.HasPrefix(name, "__Host-") || len(name) <= 7 || len(name) > 128 {
277 return false
278 }
279 for _, c := range name {
280 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
281 return false
282 }
283 }
284 return true
285}
286