greenpau / caddy-security

🔐 Authentication, Authorization, and Accounting (AAA) App and Plugin for Caddy v2. 💎 Implements Form-Based, Basic, Local, LDAP, OpenID Connect, OAuth 2.0 (Github, Google, Facebook, Okta, etc.), SAML Authentication. MFA/2FA with App Authenticators and Yubico. 💎 Authorization with JWT/PASETO tokens. 🔐
https://authcrunch.com/
Apache License 2.0
1.39k stars 70 forks source link

How do I configure CORS in Caddy security so that I can sign in less frequently? #90

Open rubydotexe opened 2 years ago

rubydotexe commented 2 years ago

Hello again!

This is the behavior I'm having trouble with:

Basically, when I am using FoundryVTT the connection to my assets disconnect until I sign in the Caddy Security portal again. Problem is that this happens frequently. When I refresh the tab, I am sent to the auth portal, I sign in, and the problem is resolved for about less then five minutes where then the cycle repeats.

I checked the JS console log:

15:03:13.429 Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://github.com/login/oauth/authorize?client_id=[omited]c7&redirect_uri=https%3A%2F%2Fauth.example.com%2Foauth2%2Fgithub%2Fauthorization-code-callback&scope=read%3Auser&state=[omited]. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 302.

15:03:13.446 Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://github.com/login/oauth/authorize?client_id=[omited]&redirect_uri=https%3A%2F%2Fauth.[omited].xyz%2Foauth2%2Fgithub%2Fauthorization-code-callback&scope=read%3Auser&state=[omited]. (Reason: CORS request did not succeed). Status code: (null).

15:03:13.447 Uncaught (in promise) TypeError: NetworkError when attempting to fetch resource.
[No packages detected]

I think it has to do with my Caddy configuration? As you can see, I basically pulled things out of my bum and have no idea what I'm doing. I tried looking up examples for CORS in Caddy and I don't think I'm doing it right. If I'm doing something wrong (and I most certainly am) can someone provide an example of what it should look like?

{
    order authenticate before respond
    order authorize before basicauth
    #acme_ca https://acme-staging-v02.api.letsencrypt.org/directory

    security {
        oauth identity provider github {env.GITHUB_CLIENT_ID} {env.GITHUB_CLIENT_SECRET}

        authentication portal myportal {
            enable identity provider github

            ui {
                links {
                    "My Website" https://example.com icon "las la-star"
                    "My Identity" "/whoami" icon "las la-user"
                }
                password_recovery_enabled no
            }
            transform user {
                match origin local
                action add role authp/user
                ui link "Portal Settings" /settings icon "las la-cog"
            }
            transform user {
                match realm github
                match sub github.com/rubydotexe
                action add role authp/user
            }
        }
        authorization policy users_policy {
            set auth url https://auth.example.com:443/
            allow roles authp/admin authp/user
            acl rule {
                comment allow users
                match role authp/user
                allow stop log info
            }
            acl rule {
                comment default deny
                match any
                deny log warn
            }
        }

        authorization policy admins_policy {
            set auth url https://auth.example.com:443/
            allow roles authp/admin authp/user
            acl rule {
                comment allow users
                match role authp/user
                allow stop log info
            }
            acl rule {
                comment default deny
                match any
                deny log warn
            }
        }
    }
}

(tls_config) {
    tls {
        dns googleclouddns {
            gcp_project {env.GCP_PROJECT}
            gcp_application_default {env.GCP_APPLICATION_DEFAULT}
        }
    }
}

(cors) {
    @origin{args.0} header Origin {args.0}
    header @origin{args.0} Access-Control-Allow-Origin "{args.0}"
    header @origin{args.0} Vary Origin
}

(wildcard) {
    Access-Control-Request-Method = method
    Access-Control-Request-Headers = 1#field-name

    wildcard = "*"
    Access-Control-Allow-Origin = origin-or-null / wildcard
    Access-Control-Allow-Credentials = %s"true" ; case-sensitive
    Access-Control-Expose-Headers = #field-name
    Access-Control-Max-Age = delta-seconds
    Access-Control-Allow-Methods = #method
    Access-Control-Allow-Headers = #field-name
}

(options) {
    header Access-Control-Allow-Methods "POST, GET, OPTIONS"
    @options {
        method OPTIONS
    }
    respond @options 204
    import cors https://example.com
    import cors https://www.example.com
    import cors https://auth.example.com
    import cors https://dnd.example.com
    import cors https://files.example.com
    import cors https://neko.example.com
    import cors https://5etools.example.com
    import cors https://wiki.example.com
    import cors https://orcpub.example.com
}

(wiki) {
    reverse_proxy app:3333
}

(errors) {
    handle /ads.txt {
        respond 404
    }
    handle /robots.txt {
        respond 404
    }
    handle /wp-content/ {
        respond 404
    }
    handle /wp-includes/ {
        respond 404
    }
    handle /wp-admin/ {
        respond 404
    }
    handle /wp-plugins/ {
        respond 404
    }
    handle /_ts_ {
        respond 404
    }
    handle /_ts/ {
        respond 404
    }
    handle /_sites/ {
        respond 404
    }
}

auth.example.com {
    import tls_config
    import options
    authenticate with myportal
    root * /usr/share/caddy
    file_server
}

example.com {
    import tls_config
    import options
    authorize with users_policy
    reverse_proxy flame:5005
}

*.example.com {
    import tls_config
    import options

    @dnd host dnd.example.com
    handle @dnd {
        authorize with users_policy
        reverse_proxy foundry:30000
        encode zstd gzip
    }

    @pub host orcpub.example.com
    handle @dnd {
        #authorize with users_policy
        reverse_proxy orcpub:8890
        route /homebrew.orcbrew {
            root /homebrew.orcbrew /srv/orcpub
            file_server
        }
    }

    @cron host cron.example.com
    handle @cron {
        authorize with users_policy
        reverse_proxy crontab-ui:8000
        encode zstd gzip
    }

    @wiki host wiki.example.com
    handle @wiki {
        authorize with users_policy
        reverse_proxy raneto:3000
    }

    @neko host neko.example.com
    handle @neko {
        authorize with users_policy
        reverse_proxy neko:8080 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-For {remote_host}
        }
    }

    @tools host 5etools.example.com
    handle @tools {
        authorize with users_policy
        reverse_proxy 5etools:80
    }

    @files host files.example.com
    handle @files {
        authorize with users_policy
        reverse_proxy filebrowser:80
    }

    # Fallback for otherwise unhandled domains
    handle {
        abort
    }
}
greenpau commented 2 years ago

I think it has to do with my Caddy configuration? As you can see, I basically pulled things out of my bum and have no idea what I'm doing. I tried looking up examples for CORS in Caddy and I don't think I'm doing it right. If I'm doing something wrong (and I most certainly am) can someone provide an example of what it should look like?

@rubydotexe , thank you for the question! 👍

Here are some snippets relevant to your CORS configuration:

auth.example.com {
    import tls_config
>   import options
    authenticate with myportal
    root * /usr/share/caddy
    file_server
}

(options) {
    header Access-Control-Allow-Methods "POST, GET, OPTIONS"
    @options {
        method OPTIONS
    }
    respond @options 204
>   import cors https://example.com
    import cors https://www.example.com
    import cors https://auth.example.com
    import cors https://dnd.example.com
    import cors https://files.example.com
    import cors https://neko.example.com
    import cors https://5etools.example.com
    import cors https://wiki.example.com
    import cors https://orcpub.example.com
}

(cors) {
    @origin{args.0} header Origin {args.0}
    header @origin{args.0} Access-Control-Allow-Origin "{args.0}"
    header @origin{args.0} Vary Origin
}

Please provide output of the following command. Let's see what headers it returns.

curl -v http://auth.example.com

It would be very interesting how the options and cors imports unwrap.

rubydotexe commented 2 years ago

Hello Mr. Greenburg! I appreciate your time very much.

This is my Caddyfile now:


{
    order authenticate before respond
    order authorize before basicauth
    #acme_ca https://acme-staging-v02.api.letsencrypt.org/directory

    security {
        oauth identity provider github {env.GITHUB_CLIENT_ID} {env.GITHUB_CLIENT_SECRET}

        authentication portal myportal {
            enable identity provider github

            ui {
                links {
                    "My Website" https://example.com icon "las la-star"
                    "My Identity" "/whoami" icon "las la-user"
                }
                password_recovery_enabled no
            }
            transform user {
                match origin local
                action add role authp/user
                ui link "Portal Settings" /settings icon "las la-cog"
            }
            transform user {
                match realm github
                match sub github.com/rubydotexe
                action add role authp/user
            }
        }
        authorization policy users_policy {
            set auth url https://auth.example.com:443/
            allow roles authp/admin authp/user
            acl rule {
                comment allow users
                match role authp/user
                allow stop log info
            }
            acl rule {
                comment default deny
                match any
                deny log warn
            }
        }

        authorization policy admins_policy {
            set auth url https://auth.example.com:443/
            allow roles authp/admin authp/user
            acl rule {
                comment allow users
                match role authp/user
                allow stop log info
            }
            acl rule {
                comment default deny
                match any
                deny log warn
            }
        }
    }
}

(tls_config) {
    tls {
        dns googleclouddns {
            gcp_project {env.GCP_PROJECT}
            gcp_application_default {env.GCP_APPLICATION_DEFAULT}
        }
    }
}

(options) {
    header Access-Control-Allow-Methods "POST, GET, OPTIONS"
    @options {
        method OPTIONS
    }
    respond @options 204
    import cors https://example.com
    import cors https://www.example.com
    import cors https://auth.example.com
    import cors https://dnd.example.com
    import cors https://files.example.com
    import cors https://neko.example.com
    import cors https://5etools.example.com
    import cors https://wiki.example.com
    import cors https://orcpub.example.com
    import cors https://cron.example.com
}

(cors) {
    @origin{args.0} header Origin {args.0}
    header @origin{args.0} Access-Control-Allow-Origin "{args.0}"
    header @origin{args.0} Vary Origin
}

auth.example.com {
    import tls_config
    import options
    authenticate with myportal
    root * /usr/share/caddy
    file_server
}

example.com {
    import tls_config
    import options
    authorize with users_policy
    reverse_proxy flame:5005
}

*.example.com {
    import tls_config
    import options

    @dnd host dnd.example.com
    handle @dnd {
        authorize with users_policy
        reverse_proxy foundry:30000
        encode zstd gzip
    }

    @pub host orcpub.example.com
    handle @dnd {
        #authorize with users_policy
        reverse_proxy orcpub:8890
        route /homebrew.orcbrew {
            root /homebrew.orcbrew /srv/orcpub
            file_server
        }
    }

    @cron host cron.example.com
    handle @cron {
        authorize with users_policy
        reverse_proxy crontab-ui:8000
        encode zstd gzip
    }

    @wiki host wiki.example.com
    handle @wiki {
        authorize with users_policy
        reverse_proxy raneto:3000
    }

    @neko host neko.example.com
    handle @neko {
        authorize with users_policy
        reverse_proxy neko:8080 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-For {remote_host}
        }
    }

    @tools host 5etools.example.com
    handle @tools {
        authorize with users_policy
        reverse_proxy 5etools:80
    }

    @files host files.example.com
    handle @files {
        authorize with users_policy
        reverse_proxy filebrowser:80
    }

    # Fallback for otherwise unhandled domains
    handle {
        abort
    }
}

And this is what I got in response to curl -v http://auth.example.com:


ruby@HOST:~$ curl -v http://auth.example.com
*   Trying [omitted]:80...
* TCP_NODELAY set
* Connected to auth.example.com ([omitted]) port 80 (#0)
> GET / HTTP/1.1
> Host: auth.example.com
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 308 Permanent Redirect
< Connection: close
< Location: https://auth.example.com/
< Server: Caddy
< Date: Thu, 21 Apr 2022 13:03:00 GMT
< Content-Length: 0
<
* Closing connection 0

And just out of curiosity I curled HTTPS as well:


ruby@CYBORG:~$ curl -v https://auth.example.com
*   Trying [omitted]:443...
* TCP_NODELAY set
* Connected to auth.example.com ([omitted]) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=auth.example.com
*  start date: Apr  3 15:20:16 2022 GMT
*  expire date: Jul  2 15:20:15 2022 GMT
*  subjectAltName: host "auth.example.com" matched cert's "auth.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55726970b880)
> GET / HTTP/2
> Host: auth.example.com
> user-agent: curl/7.68.0
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
< HTTP/2 302
< access-control-allow-methods: POST, GET, OPTIONS
< cache-control: no-store
< location: https://auth.example.com/login
< pragma: no-cache
< server: Caddy
< set-cookie: AUTHP_SESSION_ID=[omited]; Domain=example.com; Path=/; Secure; HttpOnly;
< content-length: 0
< date: Thu, 21 Apr 2022 13:03:09 GMT
<
* Connection #0 to host auth.example.com left intact
greenpau commented 2 years ago

@rubydotexe , for testing, please do the following:

auth.example.com {
    import tls_config
        # import options
        header Access-Control-Allow-Origin "*"
        header Access-Control-Allow-Methods "*"
    authenticate with myportal
    root * /usr/share/caddy
    file_server
}

Then curl the following way:

curl -v -L https://auth.example.com
rubydotexe commented 2 years ago

@greenpau Here you go:

*   Trying [omited]:443...
* TCP_NODELAY set
* Connected to auth.example.com ([omited]) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=auth.example.com
*  start date: Apr  3 15:20:16 2022 GMT
*  expire date: Jul  2 15:20:15 2022 GMT
*  subjectAltName: host "auth.example.com" matched cert's "auth.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0xaaaabda25730)
> GET / HTTP/2
> Host: auth.example.com
> user-agent: curl/7.68.0
> accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
< HTTP/2 302 
< access-control-allow-methods: *
< access-control-allow-origin: *
< cache-control: no-store
< location: https://auth.example.com/login
< pragma: no-cache
< server: Caddy
< set-cookie: AUTHP_SESSION_ID=[omitted]; Domain=example.com; Path=/; Secure; HttpOnly;
< content-length: 0
< date: Fri, 22 Apr 2022 13:34:13 GMT
< 
* Connection #0 to host auth.example.com left intact
* Issue another request to this URL: 'https://auth.example.com/login'
* Found bundle for host auth.example.com: 0xaaaabda19940 [can multiplex]
* Re-using existing connection! (#0) with host auth.example.com
* Connected to auth.example.com ([omited]) port 443 (#0)
* Using Stream ID: 3 (easy handle 0xaaaabda25730)
> GET /login HTTP/2
> Host: auth.example.com
> user-agent: curl/7.68.0
> accept: */*
> 
< HTTP/2 200 
< access-control-allow-methods: *
< access-control-allow-origin: *
< content-type: text/html
< server: Caddy
< set-cookie: AUTHP_SESSION_ID=[omitted]; Domain=example.com; Path=/; Secure; HttpOnly;
< content-length: 1856
< date: Fri, 22 Apr 2022 13:34:13 GMT
< 
<!doctype html>
<html lang="en">
  <head>
    <title>Sign In</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="Authentication Portal">
    <meta name="author" content="Paul Greenberg github.com/greenpau">
    <link rel="shortcut icon" href="/assets/images/favicon.png" type="image/png">
    <link rel="icon" href="/assets/images/favicon.png" type="image/png">

    <!-- Matrialize CSS -->
    <link rel="stylesheet" href="/assets/materialize-css/css/materialize.css" />
    <link rel="stylesheet" href="/assets/google-webfonts/roboto.css" />
    <link rel="stylesheet" href="/assets/line-awesome/line-awesome.css" />
    <link rel="stylesheet" href="/assets/css/styles.css" />

  </head>
  <body class="app-body">
    <div class="container">
      <div class="row">
        <div class="col s12 m8 offset-m2 l6 offset-l3 xl4 offset-xl4 app-card-container">
          <div class="row app-header center">

            <div class="col s4">
              <img class="d-block mx-auto mb-2" src="/assets/images/logo.svg" alt="Authentication Portal" width="72" height="72">
            </div>
            <div class="col s8">
              <h4>Sign In</h4>
            </div>

          </div>

          <div class="row">

            <a class="waves-effect waves-light grey darken-3 app-btn btn" href="oauth2/github">
              <i class="lab la-github app-btn-icon"></i><span class="app-btn-text">Github</span>
            </a>

          </div>

        </div>
      </div>
    </div>
    <!-- Optional JavaScript -->
    <script src="/assets/materialize-css/js/materialize.js"></script>

  </body>
* Connection #0 to host auth.example.com left intact
greenpau commented 2 years ago

Here you go:

@rubydotexe , do you still get Cross-Origin Request Blocked?

rubydotexe commented 2 years ago

Here you go:

@rubydotexe , do you still get Cross-Origin Request Blocked?

The console errors are at least slightly different, but I'm still having to login at short intervals.

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://github.com/login/oauth/authorize?client_id=[redacted]&redirect_uri=https%3A%2F%2Fauth.example.com%2Foauth2%2Fgithub%2Fauthorization-code-callback&scope=read%3Auser&state=[redacted]. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 302.

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://github.com/login/oauth/authorize?client_id=[redacted]&redirect_uri=https%3A%2F%2Fauth.example.com%2Foauth2%2Fgithub%2Fauthorization-code-callback&scope=read%3Auser&state=[redacted]. (Reason: CORS request did not succeed). Status code: (null).
greenpau commented 2 years ago

The console errors are at least slightly different, but I'm still having to login at short intervals.

@rubydotexe , please use Chrome and collect logs (HAR) from your session. Then, email them to me.

image

LeonardMeyer commented 2 years ago

@greenpau I have the exact same problem. I tried playing around with the CORS settings in my Caddyfile but to no avail. I have the HAR archive if you need it, what's your email ?

greenpau commented 2 years ago

@LeonardMeyer , greenpau|outlook.com

LeonardMeyer commented 2 years ago

Sent ! For context I have a Caddy container reverse proxying to subdomains pointing to several other containers .

LeonardMeyer commented 2 years ago

@greenpau Actually trying your last suggestion got me forward. I added the Access-Control headers one by one as I was told they were missing from the browser console. Then I ended up having some generic CORS error with a null message like above. MDN ended up being pretty useful , especially the "Preflight requests and credentials" part, because apparently I was missing the Access-Control-Allow-Credentials: true header. YMMV though because it seems very dependent on your browser, ad-blockers and your apps.

auth.example.com {
  route {
    header Access-Control-Allow-Origin "*"
    header Access-Control-Allow-Methods "*"
    header Access-Control-Allow-Headers "*"
    header Access-Control-Allow-Credentials "true"
    authenticate * with myportal
  }
}

I'm not fond of using the wildcard so I'll try with more specific headers (which seems not so straightforward), but it seems to work.

greenpau commented 2 years ago

I'm not fond of using the wildcard so I'll try with more specific headers (which seems not so straightforward), but it seems to work.

@LeonardMeyer , you don't have to use wilcards. They are for testing only. Now, you can set whatever domain you need it to be.

See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin

Access-Control-Allow-Origin: https://developer.mozilla.org
LeonardMeyer commented 2 years ago

@greenpau Nevermind, it looked better because I saw some preflight requests going through but it just took longer to fail. Browser console is saying things like:

Same origin policy disallows reading the remote resource https://example.com/?redirect_url=https%3A%2F%2Fsubdomain.example.com%2Fapi%2Fv1%2FindexerProxy. Reason: CORS request external redirect not allowed

So this was the first error and it matches that page. Origin of the preflight request was https://subdomain.example.com and not https://subdomain.example.com/api/v3/command, maybe that would explain that error ?

I tested in another app and what I see is this (I have the HAR if needed) :

1) Subdomain makes a request

GET /api/v1/indexerProxy HTTP/2
Host: subdomain.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
X-Api-Key: <redacted>
X-Requested-With: XMLHttpRequest
DNT: 1
Connection: keep-alive
Referer: https://subdomain.example.com/settings/indexers
Cookie: AUTHP_SESSION_ID=<redacted>; AUTHP_REDIRECT_URL=https://subdomain.example.com/index.js.map
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
TE: trailers

2) Then for some reason server goes nope and respond with HTTP 302 to the auth url and CORSExternalRedirectNotAllowed is logged in console.

HTTP/2 302 Found
location: https://example.com?redirect_url=https%3A%2F%2Fsubdomain.example.com%2Fapi%2Fv1%2FindexerProxy
server: Caddy
content-type: text/plain; charset=utf-8
content-length: 5
date: Tue, 17 May 2022 12:38:02 GMT
X-Firefox-Spdy: h2

3) A preflight request is then issued because CORS:

OPTIONS /?redirect_url=https%3A%2F%2Fsubdomain. HTTP/2
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0
Accept: */*
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Access-Control-Request-Method: GET
Access-Control-Request-Headers: x-api-key,x-requested-with
Referer: https://subdomain.example.com/
Origin: https://subdomain.example.com
DNT: 1
Connection: keep-alive
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site

4) Preflight response is issued and CORSPreflightDidNotSucceed is logged in console.

HTTP/2 302 Found
access-control-allow-credentials: true
access-control-allow-headers: *
access-control-allow-methods: *
access-control-allow-origin: *
cache-control: no-store
location: https://example.com/login
pragma: no-cache
server: Caddy
set-cookie: AUTHP_SESSION_ID=<redacted>; Domain=example.stream; Path=/; Secure; HttpOnly;
content-length: 0
date: Tue, 17 May 2022 12:38:02 GMT
X-Firefox-Spdy: h2
LeonardMeyer commented 2 years ago

@greenpau After some tinkering I ended up with the same trick to fool preflight request as @rubydotexe, drawing inspiration from this, in order to avoid the preflight requests and redirect mess.

This is my Caddyfile:

{
  order authenticate before respond

  security {

    local identity store localdb {
      realm local
      path {$CADDY_AUTH_USERS_PATH}
    }

    authentication portal myportal {
      enable identity store localdb
      cookie domain {$DOMAIN}
      cookie lifetime 43200
      ui {
        links {
          "Sonarr"    https://subdomain1.{$DOMAIN} icon "las la-star"
          "Radarr"    https://subdomain2.{$DOMAIN} icon "las la-star"
        }
      }
    }

    authorization policy admin_policy {
        set auth url https://{$DOMAIN}
        allow roles authp/admin
        acl rule {
          comment allow admins
          match role authp/admin
          allow stop log info
        }
        acl rule {
          comment default deny
          match any
          deny log warn
        }
    }
  }
}

(protected_route) {
  {args.0}.{$DOMAIN} {
    route {
      authorize with admin_policy
      reverse_proxy {args.1}
    }
  }
}

{$DOMAIN} {
  route {
    header Access-Control-Allow-Origin "*"
    header Access-Control-Allow-Methods "*"
    header Access-Control-Allow-Headers "*"
    header Access-Control-Allow-Credentials "true"
    header Access-Control-Max-Age 86400

    @options method OPTIONS
    handle @options {
      header Content-Type "text/plain charset=UTF-8"
      header Content-Length 0
      respond 204
    }
    handle {
      authenticate * with myportal
    }
  }
}
import protected_route subdomain1 subdomain1:8989
import protected_route subdomain2 subdomain2:7878

It might be bad, I'm pretty new at Caddy. With this I don't have any CORS errors anymore, but at some point it will trigger some random JS errors in the console specific to the app which failed. I can't make sense of the requests I'm seeing when one error occurs. See an example:

First request GET https://subdomain1.example.com/api/v1/notification Response

HTTP/2 302 Found
location: https://example.com/login?login_hint=webadmin%40localdomain.local&redirect_url=https%3A%2F%2Fsubdomain1.example.com%2Fapi%2Fv1%2Fnotification
server: Caddy
set-cookie: access_token=delete; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT
content-type: text/plain; charset=utf-8
content-length: 5
date: Tue, 17 May 2022 22:19:31 GMT
X-Firefox-Spdy: h2

Second request OPTIONS https://example.com/login?login_hint=webadmin@localdomain.local&redirect_url=https://subdomain1.example.com/api/v1/notification Response

HTTP/2 204 No Content
access-control-allow-credentials: true
access-control-allow-headers: *
access-control-allow-methods: *
access-control-allow-origin: *
access-control-max-age: 86400
content-type: text/plain charset=UTF-8
server: Caddy
content-length: 0
date: Tue, 17 May 2022 22:19:31 GMT
X-Firefox-Spdy: h2

Third request GET https://example.com/login?login_hint=webadmin@localdomain.local&redirect_url=https://subdomain1.example.com/api/v1/notification Response

HTTP/2 200 OK
access-control-allow-credentials: true
access-control-allow-headers: *
access-control-allow-methods: *
access-control-allow-origin: *
access-control-max-age: 86400
content-type: text/html
server: Caddy
set-cookie: AUTHP_SESSION_ID=<redacted>; Domain=example.com; Path=/; Secure; HttpOnly;
set-cookie: AUTHP_REDIRECT_URL=https://subdomain1.example.com/api/v1/notification; Domain=example.com; Path=/; Max-Age=43200; Secure; HttpOnly;
content-length: 2788
date: Tue, 17 May 2022 22:19:31 GMT
X-Firefox-Spdy: h2

I don't understand why the first request is answered 302 ? Is this a caddy-security thing ? It looks like something expired and I have to authenticate again ? Last response body is the login page HTML by the way. Following that some JS is crashing in the console and the page just fails loading the view.

greenpau commented 2 years ago

I don't understand why the first request is answered 302 ?

I recommend creating two different routes. One for API endpoints and another one for non-API endpoint. In non-API (app) polict you would set the redirect to work. In API policy, you would return 403 only, i.e. no redirect. Here is an example.

                authorization policy appPolicy {
                        set auth url /auth
                        crypto key verify CHANGE_ME
                        acl rule {
                                comment allow admins and users
                                match role authp/admin authp/user
                                allow stop log info
                        }
                        acl rule {
                                comment default deny
                                match any
                                deny log warn
                        }
                }
                authorization policy apiPolicy {
                        # do not redirect to /auth, return 403.
                        disable auth redirect
                        crypto key verify CHANGE_ME
                        acl rule {
                                comment allow admins and users
                                match role authp/admin authp/user
                                allow stop log info
                        }
                        acl rule {
                                comment default deny
                                match any
                                deny log warn
                        }
                }
(protected_route) {
  {args.0}.{$DOMAIN} {
    route /api* {
      authorize with apiPolicy
      reverse_proxy {args.1}
    }
    route {
      authorize with appPolicy
      reverse_proxy {args.1}
    }
  }
}
LeonardMeyer commented 2 years ago

Thanks for your answer @greenpau

  1. The thing is I can't say /api* because I have several subdomains and thus containers reverse proxied, and they could have any path. I could try /* I suppose but getting a 401/403 isn't the goal here anyway. Can't I just check the authorization and let the request through ?
  2. Why is it detecting an unauthenticated user ? My token has a pretty long lifetime
greenpau commented 2 years ago

@LeonardMeyer , i totally misunderstood the above. You get 302 because authorizer did not find token, or it is expired.

LeonardMeyer commented 2 years ago

@greenpau Ok actually I just saw #24 and that was why it was expiring so fast. CORS issue are also solved (as in no console errors). There's just one minor thing that is bothering me... With my Caddyfile and what happens with the 3 request/response above when token expires, the redirection to login happens only if I refresh the page. Otherwise the current page just breaks but stays on.

greenpau commented 2 years ago

There's just one minor thing that is bothering me... With my Caddyfile and what happens with the https://github.com/greenpau/caddy-security/issues/90#issuecomment-1129398616 above when token expires, the redirection to login happens only if I refresh the page. Otherwise the current page just breaks but stays on.

@LeonardMeyer , please elaborate. What is the desired behavior.

P.S. you totally hijacked the issue 😄 next time, open a new one and reference the issue that is similar to yours. Just a suggestion.

LeonardMeyer commented 2 years ago

@greenpau My bad, I did have a CORS issue initially 😅

The desired behavior should be that the redirect actually work as soon as the token is expired. But maybe CORS and preflight request is messing with said redirect.

greenpau commented 2 years ago

@LeonardMeyer , did you have a chance to review this https://github.com/greenpau/caddy-security/issues/24#issuecomment-1019633596? i.e. there is a difference between:

  crypto default token lifetime ...
  cookie lifetime ...
LeonardMeyer commented 2 years ago

@greenpau Yes, as I said in my previous comment. Session lifespan is fine now.