| 1 | // Package nativeauth provides proof-bound browser approval for native clients. |
| 2 | // Apps retain their existing session, admission and resource authorization rules. |
| 3 | package nativeauth |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/rand" |
| 8 | "crypto/sha256" |
| 9 | "crypto/subtle" |
| 10 | "encoding/base64" |
| 11 | "encoding/hex" |
| 12 | "encoding/json" |
| 13 | "html/template" |
| 14 | "io" |
| 15 | "net/http" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/rastrillo/csrf" |
| 21 | "github.com/carlosframework/rastrillo/sessions" |
| 22 | ) |
| 23 | |
| 24 | type Auth struct { |
| 25 | mu sync.Mutex |
| 26 | pending map[string]*request |
| 27 | sessions *sessions.Sessions |
| 28 | require func(http.Handler) http.Handler |
| 29 | origin, name string |
| 30 | } |
| 31 | type request struct { |
| 32 | challenge, cookie, callback string |
| 33 | expires time.Time |
| 34 | } |
| 35 | |
| 36 | func New(s *sessions.Sessions, origin, name string, require func(http.Handler) http.Handler) *Auth { |
| 37 | return &Auth{pending: map[string]*request{}, sessions: s, origin: origin, name: name, require: require} |
| 38 | } |
| 39 | func token() string { |
| 40 | b := make([]byte, 32) |
| 41 | if _, err := rand.Read(b); err != nil { |
| 42 | panic(err) |
| 43 | } |
| 44 | return base64.RawURLEncoding.EncodeToString(b) |
| 45 | } |
| 46 | func valid(s string) bool { |
| 47 | b, e := base64.RawURLEncoding.DecodeString(s) |
| 48 | return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| 49 | } |
| 50 | func code(id string) string { |
| 51 | b := sha256.Sum256([]byte(id)) |
| 52 | s := strings.ToUpper(hex.EncodeToString(b[:4])) |
| 53 | return s[:4] + "-" + s[4:] |
| 54 | } |
| 55 | func (a *Auth) find(id string) *request { |
| 56 | for key, p := range a.pending { |
| 57 | if !time.Now().Before(p.expires) { |
| 58 | delete(a.pending, key) |
| 59 | } |
| 60 | } |
| 61 | return a.pending[id] |
| 62 | } |
| 63 | func (a *Auth) pendingName() string { |
| 64 | if strings.HasPrefix(a.origin, "https:") { |
| 65 | return "__Host-native_pending" |
| 66 | } |
| 67 | return "native_pending" |
| 68 | } |
| 69 | func (a *Auth) Resume(next http.Handler) http.Handler { |
| 70 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 71 | if r.Method == "GET" && r.URL.Path == "/" { |
| 72 | if c, e := r.Cookie(a.pendingName()); e == nil && valid(c.Value) { |
| 73 | if _, ok := a.sessions.From(r); ok { |
| 74 | http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Path: "/", MaxAge: -1, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| 75 | http.Redirect(w, r, "/native/authorize?id="+c.Value, 303) |
| 76 | return |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | next.ServeHTTP(w, r) |
| 81 | }) |
| 82 | } |
| 83 | func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 84 | w.Header().Set("Cache-Control", "no-store") |
| 85 | w.Header().Set("Referrer-Policy", "no-referrer") |
| 86 | csrf.Protect(a.origin)(http.HandlerFunc(a.route)).ServeHTTP(w, r) |
| 87 | } |
| 88 | func (a *Auth) route(w http.ResponseWriter, r *http.Request) { |
| 89 | switch { |
| 90 | case r.Method == "POST" && r.URL.Path == "/native/request": |
| 91 | a.begin(w, r) |
| 92 | case r.Method == "POST" && r.URL.Path == "/native/exchange": |
| 93 | a.exchange(w, r) |
| 94 | case r.Method == "GET" && r.URL.Path == "/native/start": |
| 95 | id := r.URL.Query().Get("id") |
| 96 | a.mu.Lock() |
| 97 | ok := a.find(id) != nil |
| 98 | a.mu.Unlock() |
| 99 | if !ok { |
| 100 | http.NotFound(w, r) |
| 101 | return |
| 102 | } |
| 103 | http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Value: id, Path: "/", MaxAge: 300, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode}) |
| 104 | http.Redirect(w, r, "/", 303) |
| 105 | case r.URL.Path == "/native/authorize" && (r.Method == "GET" || r.Method == "POST"): |
| 106 | a.require(http.HandlerFunc(a.authorize)).ServeHTTP(w, r) |
| 107 | default: |
| 108 | http.NotFound(w, r) |
| 109 | } |
| 110 | } |
| 111 | func decode(w http.ResponseWriter, r *http.Request, out any) bool { |
| 112 | if r.Header.Get("Content-Type") != "application/json" { |
| 113 | http.Error(w, "Expected JSON", 415) |
| 114 | return false |
| 115 | } |
| 116 | r.Body = http.MaxBytesReader(w, r.Body, 2048) |
| 117 | d := json.NewDecoder(r.Body) |
| 118 | if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF { |
| 119 | http.Error(w, "Invalid request", 400) |
| 120 | return false |
| 121 | } |
| 122 | return true |
| 123 | } |
| 124 | func jsonReply(w http.ResponseWriter, value any) { |
| 125 | w.Header().Set("Content-Type", "application/json") |
| 126 | _ = json.NewEncoder(w).Encode(value) |
| 127 | } |
| 128 | func (a *Auth) begin(w http.ResponseWriter, r *http.Request) { |
| 129 | var in struct { |
| 130 | Challenge string `json:"challenge"` |
| 131 | Callback string `json:"callback"` |
| 132 | } |
| 133 | if !decode(w, r, &in) { |
| 134 | return |
| 135 | } |
| 136 | if !valid(in.Challenge) || (in.Callback != "" && in.Callback != a.callbackURL()) { |
| 137 | http.Error(w, "Invalid proof", 400) |
| 138 | return |
| 139 | } |
| 140 | a.mu.Lock() |
| 141 | defer a.mu.Unlock() |
| 142 | a.find("") |
| 143 | if len(a.pending) >= 256 { |
| 144 | http.Error(w, "Try again later", 429) |
| 145 | return |
| 146 | } |
| 147 | id := token() |
| 148 | a.pending[id] = &request{challenge: in.Challenge, callback: in.Callback, expires: time.Now().Add(5 * time.Minute)} |
| 149 | jsonReply(w, map[string]string{"id": id}) |
| 150 | } |
| 151 | func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) { |
| 152 | id := r.URL.Query().Get("id") |
| 153 | a.mu.Lock() |
| 154 | p := a.find(id) |
| 155 | ok := p != nil && p.cookie == "" |
| 156 | a.mu.Unlock() |
| 157 | if !ok { |
| 158 | http.NotFound(w, r) |
| 159 | return |
| 160 | } |
| 161 | done := false |
| 162 | callback := "" |
| 163 | if r.Method == "POST" { |
| 164 | c, e := r.Cookie(a.sessions.CookieName()) |
| 165 | if e != nil { |
| 166 | http.Error(w, "Sign in again", 401) |
| 167 | return |
| 168 | } |
| 169 | a.mu.Lock() |
| 170 | p = a.find(id) |
| 171 | if p == nil || p.cookie != "" { |
| 172 | a.mu.Unlock() |
| 173 | http.NotFound(w, r) |
| 174 | return |
| 175 | } |
| 176 | p.cookie = c.Value |
| 177 | if p.callback != "" { |
| 178 | callback = p.callback + "?state=" + id |
| 179 | } |
| 180 | a.mu.Unlock() |
| 181 | done = true |
| 182 | } |
| 183 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 184 | _ = approval.Execute(w, struct { |
| 185 | Name, Code, ID string |
| 186 | Done bool |
| 187 | Callback template.URL |
| 188 | }{a.name, code(id), id, done, template.URL(callback)}) |
| 189 | } |
| 190 | func (a *Auth) exchange(w http.ResponseWriter, r *http.Request) { |
| 191 | var in struct { |
| 192 | ID string `json:"id"` |
| 193 | Verifier string `json:"verifier"` |
| 194 | } |
| 195 | if !decode(w, r, &in) { |
| 196 | return |
| 197 | } |
| 198 | if !valid(in.ID) || !valid(in.Verifier) { |
| 199 | http.Error(w, "Invalid proof", 400) |
| 200 | return |
| 201 | } |
| 202 | hash := sha256.Sum256([]byte(in.Verifier)) |
| 203 | challenge := base64.RawURLEncoding.EncodeToString(hash[:]) |
| 204 | a.mu.Lock() |
| 205 | p := a.find(in.ID) |
| 206 | if p == nil || subtle.ConstantTimeCompare([]byte(p.challenge), []byte(challenge)) != 1 { |
| 207 | a.mu.Unlock() |
| 208 | http.NotFound(w, r) |
| 209 | return |
| 210 | } |
| 211 | if p.cookie == "" { |
| 212 | a.mu.Unlock() |
| 213 | w.WriteHeader(202) |
| 214 | return |
| 215 | } |
| 216 | cookie := p.cookie |
| 217 | delete(a.pending, in.ID) |
| 218 | a.mu.Unlock() |
| 219 | // Authenticate the approved cookie from scratch. The outer router may have |
| 220 | // attached a different browser session to the exchange request's context. |
| 221 | ctx, cancel := context.WithCancel(context.Background()) |
| 222 | stop := context.AfterFunc(r.Context(), cancel) |
| 223 | defer func() { stop(); cancel() }() |
| 224 | check := r.Clone(ctx) |
| 225 | check.Header = make(http.Header) |
| 226 | check.AddCookie(&http.Cookie{Name: a.sessions.CookieName(), Value: cookie}) |
| 227 | allowed := false |
| 228 | a.require(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { allowed = true })).ServeHTTP(discardResponse{}, check) |
| 229 | session, ok := a.sessions.From(check) |
| 230 | if !allowed || !ok { |
| 231 | http.Error(w, "Sign in again", 401) |
| 232 | return |
| 233 | } |
| 234 | value, err := a.sessions.Mint(session) |
| 235 | if err != nil { |
| 236 | http.Error(w, "Cannot sign in", 500) |
| 237 | return |
| 238 | } |
| 239 | jsonReply(w, map[string]string{"name": a.sessions.CookieName(), "value": value}) |
| 240 | } |
| 241 | |
| 242 | type discardResponse struct{} |
| 243 | |
| 244 | func (discardResponse) Header() http.Header { return make(http.Header) } |
| 245 | func (discardResponse) Write(b []byte) (int, error) { return len(b), nil } |
| 246 | func (discardResponse) WriteHeader(int) {} |
| 247 | |
| 248 | var approval = template.Must(template.New("approval").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in to {{.Name}}</title>{{if .Callback}}<meta http-equiv="refresh" content="0;url={{.Callback}}">{{end}}<style>:root{color-scheme:light dark}body{font:17px/1.5 system-ui,sans-serif;margin:0;background:light-dark(#f5f4f8,#19181d);color:light-dark(#24212c,#f7f4fa)}main{max-width:28rem;margin:12vh auto;padding:2rem;border-radius:1.5rem;background:light-dark(white,#28252e);box-shadow:0 8px 40px #0001}h1{font-size:1.7rem;line-height:1.2}h2{font:600 2rem ui-monospace,monospace;letter-spacing:.1em}button{font:600 1rem system-ui;border:0;border-radius:.8rem;padding:.9rem 1.2rem;background:#7052a2;color:white;cursor:pointer}button:focus-visible{outline:3px solid #ad8ddc;outline-offset:4px}@media(max-width:36rem){main{margin:2rem 1rem}}</style><body><main><h1>{{if .Done}}{{.Name}} is signed in{{else}}Sign in to {{.Name}}{{end}}</h1>{{if .Done}}{{if .Callback}}<p><a href="{{.Callback}}">Return to {{.Name}}</a></p>{{else}}<p>Return to the app to continue.</p>{{end}}{{else}}<p>Check that the app shows this same code before continuing.</p><h2>{{.Code}}</h2><form method="post" action="/native/authorize?id={{.ID}}"><button>Sign in to {{.Name}} app</button></form>{{end}}</main></body></html>`)) |
| 249 | |
| 250 | // A fixed app-specific callback prevents this endpoint becoming an open redirect. |
| 251 | // The callback only wakes the app: session exchange still requires its verifier. |
| 252 | func (a *Auth) callbackURL() string { |
| 253 | switch a.name { |
| 254 | case "Docs", "Sheets", "Calendar", "Memoria", "Meet": |
| 255 | return "works.oficina." + strings.ToLower(a.name) + "://signin" |
| 256 | default: |
| 257 | return "" |
| 258 | } |
| 259 | } |
| 260 | |