oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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