| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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 | |
| 21 | const homeInitCookie = "__Host-docs-home-init" |
| 22 | const homeProofCookie = "__Host-docs-home-proof" |
| 23 | |
| 24 | var errHomeBridge = errors.New("docs: Home exchange unavailable") |
| 25 | |
| 26 | type 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 | } |
| 39 | type homeFinishKey struct{} |
| 40 | type 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. |
| 49 | func 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. |
| 118 | func 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 | } |
| 135 | func 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 | } |
| 138 | func (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 | } |
| 152 | func (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 | } |
| 169 | func (b *homeBridge) Callback(w http.ResponseWriter, r *http.Request) { b.ordinary.Callback(w, r) } |
| 170 | func (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 | } |
| 189 | func (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 | } |
| 196 | func 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 | } |
| 202 | func (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. |
| 249 | func (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 | } |
| 276 | func (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 | } |
| 292 | func (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 | |