rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1// Package nativeauth provides proof-bound browser approval for native clients.
2// Apps retain their existing session, admission and resource authorization rules.
3package nativeauth
4
5import (
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
24type 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}
31type request struct {
32 challenge, cookie, callback string
33 expires time.Time
34}
35
36func 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}
39func 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}
46func valid(s string) bool {
47 b, e := base64.RawURLEncoding.DecodeString(s)
48 return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
49}
50func 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}
55func (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}
63func (a *Auth) pendingName() string {
64 if strings.HasPrefix(a.origin, "https:") {
65 return "__Host-native_pending"
66 }
67 return "native_pending"
68}
69func (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}
83func (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}
88func (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}
111func 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}
124func jsonReply(w http.ResponseWriter, value any) {
125 w.Header().Set("Content-Type", "application/json")
126 _ = json.NewEncoder(w).Encode(value)
127}
128func (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}
151func (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}
190func (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
242type discardResponse struct{}
243
244func (discardResponse) Header() http.Header { return make(http.Header) }
245func (discardResponse) Write(b []byte) (int, error) { return len(b), nil }
246func (discardResponse) WriteHeader(int) {}
247
248var 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.
252func (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