Repository: oauth2-proxy/oauth2-proxy
Pull request #630 (2020-06-24 07:30:18)

Gitlab project
<!--- Provide a general summary of your changes in the Title above -->

## Gitlab project

<!--- Describe your changes in detail -->

`--github-repo` is already a setting, here is the `--gitlab-project` setting.

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
Filtering by group is an oidc option, but filtering by project is easier.

<!--- If it fixes an open issue, please link to the issue here. -->

## How Has This Been Tested?

There is unit test.

Functional test are done with this command :

```
./oauth2-proxy \
    --upstream=http://localhost:5000 \
    --oidc-issuer-url=https://gitlab.example.com \
    --cookie-secret=oKailu9ees5daiW6To0aangie0ootaif \
    --client-id=blabla_id \
    --client-secret=blabla_secret \
    --gitlab-project=group/project \
    --provider gitlab \
    --scope "openid email read_api" \
    --cookie-domain project.example.com \
    --standard-logging true\
    --email-domain=example.com
```
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My change requires a change to the documentation or CHANGELOG.
- [ ] I have updated the documentation/CHANGELOG accordingly.
- [x] I have created a feature (non-master) branch for my PR.


athoune: The codeclimate advice is weird.
JoelSpeed: There are a lot of conflicts on this branch, can you rebase it and resolve the conflicts?
papey: Sure, i'll clean up the mess to give you a clean PR, thanks for all the feedback.
papey: This branch is now rebased against master, there is still some real world tests to do to ensure we do not hit some corner cases.

In this implementation, a user can not access an archived repo, this behavior can be discuss if needed since archived can not mean the same thing for each users.

I think all the requested changes are updated with all the advices and insights we discuss in this conversation.
papey: I've test it against our production server, users member of target project can access resources, users not member of target project get a 403.

Do you have any rules/guide to update Docs and Changelog ?

Thanks.
NickMeves: Tangentially related to this PR, but likely too much scope creep, better in a different PR:

https://github.com/oauth2-proxy/oauth2-proxy/pull/767#issuecomment-697731617

How do we want to support gitlab group or project AuthZ in the new `X-Forwarded-Groups`? Add Gitlab groups to the `session.Groups` field and ignore projects completely?

I'm curious your thoughts as a Gitlab provider user (I don't use this provider) -- I want to get all our Providers that have some form of Group AuthZ logic to pass the groups data to the backend via the `session.Groups` field moving forward.
papey: > How do we want to support gitlab group or project AuthZ in the new `X-Forwarded-Groups`? Add Gitlab groups to the `session.Groups` field and ignore projects completely?

No sure about the semantic of the `Groups` keyword but projects can be seen as a *Group* of users (related to this project) too.

For now the matching project is not returned on success but it's quite easy to add this variable in the early return (https://github.com/oauth2-proxy/oauth2-proxy/pull/630/files#diff-83fab261d52ee47763d81d669a7b7b74R295). 

> I'm curious your thoughts as a Gitlab provider user (I don't use this provider) -- I want to get all our Providers that have some form of Group AuthZ logic to pass the groups data to the backend via the `session.Groups` field moving forward.

One way could be add a dedicated header for projects, since Github also have this feature. The other way could be passing the projects in the group header since a project is, at the end, a group of users.
NickMeves: FYI - I have 2 PRs very related to this: #796 & #797 

Once they merge I will circle back and we can get this finalized and merged in as well.  I really want to clean up the post token redeem session enrichment process (where a lot of providers have hacked in authorization logic into GetEmailAddress) and get a centralized method for authorization under a global `Authorize` provider method.

In my refactor in #767 I noted the area where Gitlab had hacky AuthZ in an improper spot and added a TODO note.
papey: Indeed, it looks like there is a lot of plumbing stuff to do to harmonize all the things and remove all the accumlated layers of hacks over hacks.

For now, the impact on this PR looks minimal and I hope it will not invalidate the work and efforts I put in.
NickMeves: Sorry for the delay.  If you still want to get this over the finish line:

https://github.com/oauth2-proxy/oauth2-proxy/pull/797 merged.

The refactor to `EnrichSessionState` & `Authorize` provider interface methods are now in master. This should be completely unblocked now.
papey: Hi @NickMeves, thanks for your message and status update. I will work on this next week if I find the bandwith. :+1: 
NickMeves: > Hi @NickMeves, thanks for your message and status update. I will work on this next week if I find the bandwith. 👍

Thanks again for taking this on!

I think the only change is to add any projects (or whatever else) into the session.Groups list in `EnrichSessionState`.  That way it will automatically be available to be used by the default `Authorize` method if any users of this provider want authorization.

I think for things like this with multiple group-link entities, @JoelSpeed and I were leaning toward a prefix to deconflict between the possible options. (e.g. `project:blahblah`) being the string added to `session.Groups`.
papey: Here is a starting point using `EnrichSessionState`, consider this as WIP but comments and feedback are welcome. 

I squashed everything in order to make things more readable. I also made a copy of the old branch, just for sanity (see [gitlab_project-before-enrich](https://github.com/factorysh/oauth2-proxy/tree/gitlab_project-before-enrich)).

I tested it on our test project and it seems to work (needs a recheck with a fresh brain)

Also a question/opinion :

For now, we loop over each project in the config file, impersonating the user to see if project is accessible (see [gitlab.go](https://github.com/factorysh/oauth2-proxy/blob/gitlab_project/providers/gitlab.go#L276)) and adding it to the session state group. In this case : 1 project in the config file means 1 request to Gitlab. 

I think I will try to go reverse and fetch all the projects of the user and then compare it to the ones listed in the config file.

What do you think ?

Thanks for reading/feedback.

papey: @NickMeves as discussed before, I added a project access level config to have a great fine tunning on projects filters.

Not sure about why the CI failed since tests are marked :ok: 
papey: Looks good, I will make a final test in our preproduction env to ensure everything works great.
NickMeves: > Looks good, I will make a final test in our preproduction env to ensure everything works great.
> 
> Edit : what is the best way to tell users that gitlab projects needs the extra `read_api` scope from Gitlab ?

A bullet in `Important Notes` in the CHANGELOG and updating the provider documentation for Gitlab with the new setup details here: https://github.com/oauth2-proxy/oauth2-proxy/blob/master/docs/docs/configuration/auth.md#gitlab-auth-provider
papey: Rebase on last changes from master, will make manual tests ASAP
papey: Hi @NickMeves, luckily for us, I catch some bugs with my end to end test on our Gitlab.

The first was a tricky one, caused by https://github.com/oauth2-proxy/oauth2-proxy/commit/22f60e9b63e54e8a0fc6b625280f05159b5560a5 and fixed by https://github.com/oauth2-proxy/oauth2-proxy/pull/630/commits/78a80da052d6da03b685636c0ad8f03c6a1e548d. The Verifier data was overwritten by the GitlabProvider struct init. Just before Verifier nil check, the GitlabProvider code set it to nil, as a consequence the OIDC Verifier was always set https://gitlab.com, even if another one was specified in the configuration.

The second one was just missing info in log messages due to values push to session state after methods call, fixed by https://github.com/oauth2-proxy/oauth2-proxy/pull/630/commits/b33da60d1242d6ce96be7488c94243516f7dd427

LGTM.
NickMeves: > Hi @NickMeves, luckily for us, I catch some bugs with my end to end test on our Gitlab.
> 
> The first was a tricky one, caused by [22f60e9](https://github.com/oauth2-proxy/oauth2-proxy/commit/22f60e9b63e54e8a0fc6b625280f05159b5560a5) and fixed by [78a80da](https://github.com/oauth2-proxy/oauth2-proxy/commit/78a80da052d6da03b685636c0ad8f03c6a1e548d). The Verifier data was overwritten by the GitlabProvider struct init. Just before Verifier nil check, the GitlabProvider code set it to nil, as a consequence the OIDC Verifier was always set https://gitlab.com, even if another one was specified in the configuration.
> 
> The second one was just missing info in log messages due to values pushed to session state after methods call, fixed by [b33da60](https://github.com/oauth2-proxy/oauth2-proxy/commit/b33da60d1242d6ce96be7488c94243516f7dd427)
> 
> LGTM.

Good find. I some some OIDC aspects of this provider I will need to address in this PR that looks to make OIDC functionality more global, I'll make a commit for that: https://github.com/oauth2-proxy/oauth2-proxy/pull/936

Out of curiosity, what do GitLab IDToken claims look like? It is only populating the session fields with `Userinfo` details and doesn't pull anything from the claims like the OIDC Provider.

We are looking to support adhoc claims -> session -> headers. If Gitlab has interesting claims in the IDToken, I imagine it might want to support this feature?
papey: Everything should be good, added a test case for archived project.
papey: > Out of curiosity, what do GitLab IDToken claims look like? It is only populating the session fields with Userinfo details and doesn't pull anything from the claims like the OIDC Provider.
> We are looking to support adhoc claims -> session -> headers. If Gitlab has interesting claims in the IDToken, I imagine it might want to support this feature?

sub | string | The ID of the user
-- | -- | --
sub_legacy | string | An opaque token that uniquely identifies the userDeprecation notice: this token isn’t stable because it’s tied to the Rails secret key base, and is provided only for migration to the new stable sub value available from GitLab 11.1
auth_time | integer | The timestamp for the user’s last authentication
name | string | The user’s full name
nickname | string | The user’s GitLab username
email | string | The user’s email addressThis is the user’s primary email address if the application has access to the email claim and the user’s public email address otherwise
email_verified | boolean | Whether the user’s email address was verified
website | string | URL for the user’s website
profile | string | URL for the user’s GitLab profile
picture | string | URL for the user’s GitLab avatar
groups | array | Names of the groups the user is a member of

Source : https://docs.gitlab.com/ee/integration/openid_connect_provider.html

> Or are you cool if I squash everything into a merge commit to keep the commit history cleaner?

Sure you can squash :+1: 

Thanks a lot for your patience, the instant feedback on this topic was :+1:.
NickMeves: @papey Can you rebase from latest master? Then I'll merge
papey: Rebased against master :+1: 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 330b49de..2f6065b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,13 +4,14 @@
 
 ## Important Notes
 
+- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Gitlab projects needs a Gitlab application with the extra `read_api` enabled
 - [#905](https://github.com/oauth2-proxy/oauth2-proxy/pull/905) Existing sessions from v6.0.0 or earlier are no longer valid. They will trigger a reauthentication.
 - [#826](https://github.com/oauth2-proxy/oauth2-proxy/pull/826) `skip-auth-strip-headers` now applies to all requests, not just those where authentication would be skipped.
 - [#797](https://github.com/oauth2-proxy/oauth2-proxy/pull/797) The behavior of the Google provider Groups restriction changes with this
   - Either `--google-group` or the new `--allowed-group` will work for Google now (`--google-group` will be used if both are set)
   - Group membership lists will be passed to the backend with the `X-Forwarded-Groups` header
   - If you change the list of allowed groups, existing sessions that now don't have a valid group will be logged out immediately.
-      - Previously, group membership was only checked on session creation and refresh.
+    - Previously, group membership was only checked on session creation and refresh.
 - [#789](https://github.com/oauth2-proxy/oauth2-proxy/pull/789) `--skip-auth-route` is (almost) backwards compatible with `--skip-auth-regex`
   - We are marking `--skip-auth-regex` as DEPRECATED and will remove it in the next major version.
   - If your regex contains an `=` and you want it for all methods, you will need to add a leading `=` (this is the area where `--skip-auth-regex` doesn't port perfectly)
@@ -38,11 +39,12 @@
   be any redirects in the browser anymore when tokens expire, but instead a token refresh is initiated
   in the background, which leads to new tokens being returned in the cookies.
   - Please note that `--cookie-refresh` must be 0 (the default) or equal to the token lifespan configured in Azure AD to make
-  Azure token refresh reliable. Setting this value to 0 means that it relies on the provider implementation
-  to decide if a refresh is required.
+    Azure token refresh reliable. Setting this value to 0 means that it relies on the provider implementation
+    to decide if a refresh is required.
 
 ## Changes since v6.1.1
 
+- [#630](https://github.com/oauth2-proxy/oauth2-proxy/pull/630) Add support for Gitlab project based authentication (@factorysh)
 - [#907](https://github.com/oauth2-proxy/oauth2-proxy/pull/907) Introduce alpha configuration option to enable testing of structured configuration (@JoelSpeed)
 - [#938](https://github.com/oauth2-proxy/oauth2-proxy/pull/938) Cleanup missed provider renaming refactor methods (@NickMeves)
 - [#925](https://github.com/oauth2-proxy/oauth2-proxy/pull/925) Fix basic auth legacy header conversion (@JoelSpeed)
@@ -78,7 +80,6 @@
 - [#829](https://github.com/oauth2-proxy/oauth2-proxy/pull/820) Rename test directory to testdata (@johejo)
 - [#819](https://github.com/oauth2-proxy/oauth2-proxy/pull/819) Improve CI (@johejo)
 
-
 # v6.1.1
 
 ## Release Highlights
@@ -180,7 +181,7 @@ N/A
 - [#440](https://github.com/oauth2-proxy/oauth2-proxy/pull/440) Switch Azure AD Graph API to Microsoft Graph API
   - The Azure AD Graph API has been [deprecated](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-graph-api) and is being replaced by the Microsoft Graph API.
     If your application relies on the access token being passed to it to access the Azure AD Graph API, you should migrate your application to use the Microsoft Graph API.
-    Existing behaviour can be retained by setting  `-resource=https://graph.windows.net`.
+    Existing behaviour can be retained by setting `-resource=https://graph.windows.net`.
 - [#484](https://github.com/oauth2-proxy/oauth2-proxy/pull/484) Configuration loading has been replaced with Viper and PFlag
   - Flags now require a `--` prefix before the option
   - Previously flags allowed either `-` or `--` to prefix the option name
@@ -201,7 +202,7 @@ N/A
 - [#556](https://github.com/oauth2-proxy/oauth2-proxy/pull/556) Remove unintentional auto-padding of secrets that were too short
   - Previously, after cookie-secrets were opportunistically base64 decoded to raw bytes,
     they were padded to have a length divisible by 4.
-  - This led to wrong sized secrets being valid AES lengths of 16, 24, or 32  bytes. Or it led to confusing errors
+  - This led to wrong sized secrets being valid AES lengths of 16, 24, or 32 bytes. Or it led to confusing errors
     reporting an invalid length of 20 or 28 when the user input cookie-secret was not that length.
   - Now we will only base64 decode a cookie-secret to raw bytes if it is 16, 24, or 32 bytes long. Otherwise, we will convert
     the direct cookie-secret to bytes without silent padding added.
@@ -306,15 +307,18 @@ N/A
 # v5.1.0
 
 ## Release Highlights
+
 - Bump to Go 1.14
 - Reduced number of Google API requests for group validation
 - Support for Redis Cluster
 - Support for overriding hosts in hosts file
 
 ## Important Notes
+
 - [#335] The session expiry for the OIDC provider is now taken from the Token Response (expires_in) rather than from the id_token (exp)
 
 ## Breaking Changes
+
 N/A
 
 ## Changes since v5.0.0
@@ -338,13 +342,15 @@ N/A
 # v5.0.0
 
 ## Release Highlights
+
 - Disabled CGO (binaries will work regardless og glibc/musl)
 - Allow whitelisted redirect ports
 - Nextcloud provider support added
 - DigitalOcean provider support added
 
 ## Important Notes
-- (Security) Fix for [open redirect vulnerability](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-qqxw-m5fj-f7gv)..  a bad actor using `/\` in redirect URIs can redirect a session to another domain
+
+- (Security) Fix for [open redirect vulnerability](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-qqxw-m5fj-f7gv).. a bad actor using `/\` in redirect URIs can redirect a session to another domain
 
 ## Breaking Changes
 
@@ -365,6 +371,7 @@ N/A
 # v4.1.0
 
 ## Release Highlights
+
 - Added Keycloak provider
 - Build on Go 1.13
 - Upgrade Docker image to use Debian Buster
@@ -373,12 +380,15 @@ N/A
 - Added support for GitHub teams
 
 ## Important Notes
+
 N/A
 
 ## Breaking Changes
+
 N/A
 
 ## Changes since v4.0.0
+
 - [#292](https://github.com/oauth2-proxy/oauth2-proxy/pull/292) Added bash >= 4.0 dependency to configure script (@jmfrank63)
 - [#227](https://github.com/oauth2-proxy/oauth2-proxy/pull/227) Add Keycloak provider (@Ofinka)
 - [#259](https://github.com/oauth2-proxy/oauth2-proxy/pull/259) Redirect to HTTPS (@jmickey)
@@ -401,6 +411,7 @@ N/A
 # v4.0.0
 
 ## Release Highlights
+
 - Documentation is now on a [microsite](https://oauth2-proxy.github.io/oauth2-proxy/)
 - Health check logging can now be disabled for quieter logs
 - Authorization Header JWTs can now be verified by the proxy to skip authentication for machine users
@@ -408,29 +419,30 @@ N/A
 - Logging overhaul allows customisable logging formats
 
 ## Important Notes
+
 - This release includes a number of breaking changes that will require users to
-reconfigure their proxies. Please read the Breaking Changes below thoroughly.
+  reconfigure their proxies. Please read the Breaking Changes below thoroughly.
 
 ## Breaking Changes
 
 - [#231](https://github.com/oauth2-proxy/oauth2-proxy/pull/231) Rework GitLab provider
   - This PR changes the configuration options for the GitLab provider to use
-  a self-hosted instance. You now need to specify a `-oidc-issuer-url` rather than
-  explicit `-login-url`, `-redeem-url` and `-validate-url` parameters.
+    a self-hosted instance. You now need to specify a `-oidc-issuer-url` rather than
+    explicit `-login-url`, `-redeem-url` and `-validate-url` parameters.
 - [#186](https://github.com/oauth2-proxy/oauth2-proxy/pull/186) Make config consistent
   - This PR changes configuration options so that all flags have a config counterpart
-  of the same name but with underscores (`_`) in place of hyphens (`-`).
-  This change affects the following flags:
+    of the same name but with underscores (`_`) in place of hyphens (`-`).
+    This change affects the following flags:
   - The `--tls-key` flag is now `--tls-key-file` to be consistent with existing
-  file flags and the existing config and environment settings
+    file flags and the existing config and environment settings
   - The `--tls-cert` flag is now `--tls-cert-file` to be consistent with existing
-  file flags and the existing config and environment settings
-  This change affects the following existing configuration options:
+    file flags and the existing config and environment settings
+    This change affects the following existing configuration options:
   - The `proxy-prefix` option is now `proxy_prefix`.
-  This PR changes environment variables so that all flags have an environment
-  counterpart of the same name but capitalised, with underscores (`_`) in place
-  of hyphens (`-`) and with the prefix `OAUTH2_PROXY_`.
-  This change affects the following existing environment variables:
+    This PR changes environment variables so that all flags have an environment
+    counterpart of the same name but capitalised, with underscores (`_`) in place
+    of hyphens (`-`) and with the prefix `OAUTH2_PROXY_`.
+    This change affects the following existing environment variables:
   - The `OAUTH2_SKIP_OIDC_DISCOVERY` environment variable is now `OAUTH2_PROXY_SKIP_OIDC_DISCOVERY`.
   - The `OAUTH2_OIDC_JWKS_URL` environment variable is now `OAUTH2_PROXY_OIDC_JWKS_URL`.
 - [#146](https://github.com/oauth2-proxy/oauth2-proxy/pull/146) Use full email address as `User` if the auth response did not contain a `User` field
@@ -456,7 +468,7 @@ reconfigure their proxies. Please read the Breaking Changes below thoroughly.
 - [#65](https://github.com/oauth2-proxy/oauth2-proxy/pull/65) Improvements to authenticate requests with a JWT bearer token in the `Authorization` header via
   the `-skip-jwt-bearer-token` options. (@brianv0)
   - Additional verifiers can be configured via the `-extra-jwt-issuers` flag if the JWT issuers is either an OpenID provider or has a JWKS URL
-  (e.g. `https://example.com/.well-known/jwks.json`).
+    (e.g. `https://example.com/.well-known/jwks.json`).
 - [#180](https://github.com/oauth2-proxy/oauth2-proxy/pull/180) Minor refactor of core proxying path (@aeijdenberg).
 - [#175](https://github.com/oauth2-proxy/oauth2-proxy/pull/175) Bump go-oidc to v2.0.0 (@aeijdenberg).
   - Includes fix for potential signature checking issue when OIDC discovery is skipped.
@@ -514,6 +526,7 @@ reconfigure their proxies. Please read the Breaking Changes below thoroughly.
 # v3.2.0
 
 ## Release highlights
+
 - Internal restructure of session state storage to use JSON rather than proprietary scheme
 - Added health check options for running on GCP behind a load balancer
 - Improved support for protecting websockets
@@ -521,9 +534,10 @@ reconfigure their proxies. Please read the Breaking Changes below thoroughly.
 - Allow manual configuration of OIDC providers
 
 ## Important notes
+
 - Dockerfile user is now non-root, this may break your existing deployment
 - In the OIDC provider, when no email is returned, the ID Token subject will be used
-instead of returning an error
+  instead of returning an error
 - GitHub user emails must now be primary and verified before authenticating
 
 ## Changes since v3.1.0
diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md
index e05c0edb..30973efc 100644
--- a/docs/docs/configuration/auth.md
+++ b/docs/docs/configuration/auth.md
@@ -149,6 +149,8 @@ The group management in keycloak is using a tree. If you create a group named ad
 
 Whether you are using GitLab.com or self-hosting GitLab, follow [these steps to add an application](https://docs.gitlab.com/ce/integration/oauth_provider.html). Make sure to enable at least the `openid`, `profile` and `email` scopes, and set the redirect url to your application url e.g. https://myapp.com/oauth2/callback.
 
+If you need projects filtering, add the extra `read_api` scope to your application.
+
 The following config should be set to ensure that the oauth will work properly. To get a cookie secret follow [these steps](https://github.com/oauth2-proxy/oauth2-proxy/blob/master/docs/configuration/configuration.md#configuration)
 
 ```
diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md
index 76e7400f..46e72d67 100644
--- a/docs/docs/configuration/overview.md
+++ b/docs/docs/configuration/overview.md
@@ -54,6 +54,7 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
 | `--github-token` | string | the token to use when verifying repository collaborators (must have push access to the repository) | |
 | `--github-user` | string \| list | To allow users to login by username even if they do not belong to the specified org and team or collaborators | |
 | `--gitlab-group` | string \| list | restrict logins to members of any of these groups (slug), separated by a comma | |
+| `--gitlab-projects` | string \| list | restrict logins to members of any of these projects (may be given multiple times) formatted as `orgname/repo=accesslevel`. Access level should be a value matching [Gitlab access levels](https://docs.gitlab.com/ee/api/members.html#valid-access-levels), defaulted to 20 if absent | |
 | `--google-admin-email` | string | the google admin to impersonate for api calls | |
 | `--google-group` | string | restrict logins to members of this google group (may be given multiple times). | |
 | `--google-service-account-json` | string | the path to the service account json credentials | |
diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go
index 6cb95f54..cf4e2414 100644
--- a/pkg/apis/options/options.go
+++ b/pkg/apis/options/options.go
@@ -48,6 +48,7 @@ type Options struct {
 	GitHubToken              string   `flag:"github-token" cfg:"github_token"`
 	GitHubUsers              []string `flag:"github-user" cfg:"github_users"`
 	GitLabGroup              []string `flag:"gitlab-group" cfg:"gitlab_groups"`
+	GitlabProjects           []string `flag:"gitlab-project" cfg:"gitlab_projects"`
 	GoogleGroups             []string `flag:"google-group" cfg:"google_group"`
 	GoogleAdminEmail         string   `flag:"google-admin-email" cfg:"google_admin_email"`
 	GoogleServiceAccountJSON string   `flag:"google-service-account-json" cfg:"google_service_account_json"`
@@ -188,6 +189,7 @@ func NewFlagSet() *pflag.FlagSet {
 	flagSet.String("github-token", "", "the token to use when verifying repository collaborators (must have push access to the repository)")
 	flagSet.StringSlice("github-user", []string{}, "allow users with these usernames to login even if they do not belong to the specified org and team or collaborators (may be given multiple times)")
 	flagSet.StringSlice("gitlab-group", []string{}, "restrict logins to members of this group (may be given multiple times)")
+	flagSet.StringSlice("gitlab-project", []string{}, "restrict logins to members of this project (may be given multiple times) (eg `group/project=accesslevel`). Access level should be a value matching Gitlab access levels (see https://docs.gitlab.com/ee/api/members.html#valid-access-levels), defaulted to 20 if absent")
 	flagSet.StringSlice("google-group", []string{}, "restrict logins to members of this google group (may be given multiple times).")
 	flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls")
 	flagSet.String("google-service-account-json", "", "the path to the service account json credentials")
diff --git a/pkg/validation/options.go b/pkg/validation/options.go
index 15cd374e..a13d8469 100644
--- a/pkg/validation/options.go
+++ b/pkg/validation/options.go
@@ -282,6 +282,12 @@ func parseProviderInfo(o *options.Options, msgs []string) []string {
 	case *providers.GitLabProvider:
 		p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
 		p.Groups = o.GitLabGroup
+		err := p.AddProjects(o.GitlabProjects)
+		if err != nil {
+			msgs = append(msgs, "failed to setup gitlab project access level")
+		}
+		p.SetAllowedGroups(p.PrefixAllowedGroups())
+		p.SetProjectScope()
 
 		if p.Verifier == nil {
 			// Initialize with default verifier for gitlab.com
diff --git a/providers/gitlab.go b/providers/gitlab.go
index 3941235c..246dd78c 100644
--- a/providers/gitlab.go
+++ b/providers/gitlab.go
@@ -3,10 +3,13 @@ package providers
 import (
 	"context"
 	"fmt"
+	"net/url"
+	"strconv"
+	"strings"
 	"time"
 
-	oidc "github.com/coreos/go-oidc"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
 	"golang.org/x/oauth2"
 )
@@ -15,11 +18,54 @@ import (
 type GitLabProvider struct {
 	*ProviderData
 
-	Groups               []string
-	Verifier             *oidc.IDTokenVerifier
+	Groups   []string
+	Projects []*GitlabProject
+
 	AllowUnverifiedEmail bool
 }
 
+// GitlabProject represents a Gitlab project constraint entity
+type GitlabProject struct {
+	Name        string
+	AccessLevel int
+}
+
+// newGitlabProject Creates a new GitlabProject struct from project string formatted as namespace/project=accesslevel
+// if no accesslevel provided, use the default one
+func newGitlabproject(project string) (*GitlabProject, error) {
+	// default access level is 20
+	defaultAccessLevel := 20
+	// see https://docs.gitlab.com/ee/api/members.html#valid-access-levels
+	validAccessLevel := [4]int{10, 20, 30, 40}
+
+	parts := strings.SplitN(project, "=", 2)
+
+	if len(parts) == 2 {
+		lvl, err := strconv.Atoi(parts[1])
+		if err != nil {
+			return nil, err
+		}
+
+		for _, valid := range validAccessLevel {
+			if lvl == valid {
+				return &GitlabProject{
+						Name:        parts[0],
+						AccessLevel: lvl},
+					err
+			}
+		}
+
+		return nil, fmt.Errorf("invalid gitlab project access level specified (%s)", parts[0])
+
+	}
+
+	return &GitlabProject{
+			Name:        project,
+			AccessLevel: defaultAccessLevel},
+		nil
+
+}
+
 var _ Provider = (*GitLabProvider)(nil)
 
 const (
@@ -64,6 +110,19 @@ func (p *GitLabProvider) Redeem(ctx context.Context, redirectURL, code string) (
 	return
 }
 
+// SetProjectScope ensure read_api is added to scope when filtering on projects
+func (p *GitLabProvider) SetProjectScope() {
+	if len(p.Projects) > 0 {
+		for _, val := range strings.Split(p.Scope, " ") {
+			if val == "read_api" {
+				return
+			}
+
+		}
+		p.Scope += " read_api"
+	}
+}
+
 // RefreshSessionIfNeeded checks if the session has expired and uses the
 // RefreshToken to fetch a new ID token if required
 func (p *GitLabProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.SessionState) (bool, error) {
@@ -144,25 +203,56 @@ func (p *GitLabProvider) getUserInfo(ctx context.Context, s *sessions.SessionSta
 	return &userInfo, nil
 }
 
-func (p *GitLabProvider) verifyGroupMembership(userInfo *gitlabUserInfo) error {
-	if len(p.Groups) == 0 {
-		return nil
+type gitlabPermissionAccess struct {
+	AccessLevel int `json:"access_level"`
+}
+
+type gitlabProjectPermission struct {
+	ProjectAccess *gitlabPermissionAccess `json:"project_access"`
+	GroupAccess   *gitlabPermissionAccess `json:"group_access"`
+}
+
+type gitlabProjectInfo struct {
+	Name              string                  `json:"name"`
+	Archived          bool                    `json:"archived"`
+	PathWithNamespace string                  `json:"path_with_namespace"`
+	Permissions       gitlabProjectPermission `json:"permissions"`
+}
+
+func (p *GitLabProvider) getProjectInfo(ctx context.Context, s *sessions.SessionState, project string) (*gitlabProjectInfo, error) {
+	var projectInfo gitlabProjectInfo
+
+	endpointURL := &url.URL{
+		Scheme: p.LoginURL.Scheme,
+		Host:   p.LoginURL.Host,
+		Path:   "/api/v4/projects/",
 	}
 
-	// Collect user group memberships
-	membershipSet := make(map[string]bool)
-	for _, group := range userInfo.Groups {
-		membershipSet[group] = true
+	err := requests.New(fmt.Sprintf("%s%s", endpointURL.String(), url.QueryEscape(project))).
+		WithContext(ctx).
+		SetHeader("Authorization", "Bearer "+s.AccessToken).
+		Do().
+		UnmarshalInto(&projectInfo)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to get project info: %v", err)
 	}
 
-	// Find a valid group that they are a member of
-	for _, validGroup := range p.Groups {
-		if _, ok := membershipSet[validGroup]; ok {
-			return nil
+	return &projectInfo, nil
+}
+
+// AddProjects adds Gitlab projects from options to GitlabProvider struct
+func (p *GitLabProvider) AddProjects(projects []string) error {
+	for _, project := range projects {
+		gp, err := newGitlabproject(project)
+		if err != nil {
+			return err
 		}
+
+		p.Projects = append(p.Projects, gp)
 	}
 
-	return fmt.Errorf("user is not a member of '%s'", p.Groups)
+	return nil
 }
 
 func (p *GitLabProvider) createSessionState(ctx context.Context, token *oauth2.Token) (*sessions.SessionState, error) {
@@ -193,7 +283,7 @@ func (p *GitLabProvider) ValidateSession(ctx context.Context, s *sessions.Sessio
 	return err == nil
 }
 
-// GetEmailAddress returns the Account email address
+// EnrichSession adds values and data from the Gitlab endpoint to current session
 func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
 	// Retrieve user info
 	userInfo, err := p.getUserInfo(ctx, s)
@@ -206,15 +296,67 @@ func (p *GitLabProvider) EnrichSession(ctx context.Context, s *sessions.SessionS
 		return fmt.Errorf("user email is not verified")
 	}
 
-	// Check group membership
-	// TODO (@NickMeves) - Refactor to Authorize
-	err = p.verifyGroupMembership(userInfo)
-	if err != nil {
-		return fmt.Errorf("group membership check failed: %v", err)
-	}
-
 	s.User = userInfo.Username
 	s.Email = userInfo.Email
 
+	p.addGroupsToSession(ctx, s)
+
+	p.addProjectsToSession(ctx, s)
+
 	return nil
+
+}
+
+// addGroupsToSession projects into session.Groups
+func (p *GitLabProvider) addGroupsToSession(ctx context.Context, s *sessions.SessionState) {
+	// Iterate over projects, check if oauth2-proxy can get project information on behalf of the user
+	for _, group := range p.Groups {
+		s.Groups = append(s.Groups, fmt.Sprintf("group:%s", group))
+	}
+}
+
+// addProjectsToSession adds projects matching user access requirements into the session state groups list
+// This method prefix projects names with `project` to specify group kind
+func (p *GitLabProvider) addProjectsToSession(ctx context.Context, s *sessions.SessionState) {
+	// Iterate over projects, check if oauth2-proxy can get project information on behalf of the user
+	for _, project := range p.Projects {
+		projectInfo, err := p.getProjectInfo(ctx, s, project.Name)
+
+		if err != nil {
+			logger.Errorf("Warning: project info request failed: %v", err)
+			continue
+		}
+
+		if !projectInfo.Archived {
+			perms := projectInfo.Permissions.ProjectAccess
+			if perms == nil {
+				// use group project access as fallback
+				perms = projectInfo.Permissions.GroupAccess
+			}
+
+			if perms.AccessLevel >= project.AccessLevel {
+				s.Groups = append(s.Groups, fmt.Sprintf("project:%s", project.Name))
+			} else {
+				logger.Errorf("Warning: user %q does not have the minimum required access level for project %q", s.Email, project.Name)
+			}
+		} else {
+			logger.Errorf("Warning: project %s is archived", project.Name)
+		}
+
+	}
+
+}
+
+// PrefixAllowedGroups returns a list of allowed groups, prefixed by their `kind` value
+func (p *GitLabProvider) PrefixAllowedGroups() (groups []string) {
+
+	for _, val := range p.Groups {
+		groups = append(groups, fmt.Sprintf("group:%s", val))
+	}
+
+	for _, val := range p.Projects {
+		groups = append(groups, fmt.Sprintf("project:%s", val.Name))
+	}
+
+	return groups
 }


Test patch:
diff --git a/providers/gitlab_test.go b/providers/gitlab_test.go
index e3d974bf..62f0a70a 100644
--- a/providers/gitlab_test.go
+++ b/providers/gitlab_test.go
@@ -2,13 +2,15 @@ package providers
 
 import (
 	"context"
+	"errors"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
-	"testing"
 
 	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
-	"github.com/stretchr/testify/assert"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
 )
 
 func testGitLabProvider(hostname string) *GitLabProvider {
@@ -39,100 +41,241 @@ func testGitLabBackend() *httptest.Server {
 			"groups": ["foo", "bar"]
 		}
 	`
+	projectInfo := `
+		{
+			"name": "MyProject",
+			"archived": false,
+			"path_with_namespace": "my_group/my_project",
+			"permissions": {
+				"project_access": null,
+				"group_access": {
+					"access_level": 30,
+					"notification_level": 3
+				}
+			}
+		}
+	`
+
+	personalProjectInfo := `
+		{
+			"name": "MyPersonalProject",
+			"archived": false,
+			"path_with_namespace": "my_profile/my_personal_project",
+			"permissions": {
+				"project_access": {
+					"access_level": 30,
+					"notification_level": 3
+				},
+				"group_access": null
+			}
+		}
+	`
+
+	archivedProjectInfo := `
+		{
+			"name": "MyArchivedProject",
+			"archived": true,
+			"path_with_namespace": "my_group/my_archived_project",
+			"permissions": {
+				"project_access": {
+					"access_level": 30,
+					"notification_level": 3
+				},
+				"group_access": null
+			}
+		}
+	`
+
 	authHeader := "Bearer gitlab_access_token"
 
 	return httptest.NewServer(http.HandlerFunc(
 		func(w http.ResponseWriter, r *http.Request) {
-			if r.URL.Path == "/oauth/userinfo" {
+			switch r.URL.Path {
+			case "/oauth/userinfo":
 				if r.Header["Authorization"][0] == authHeader {
 					w.WriteHeader(200)
 					w.Write([]byte(userInfo))
 				} else {
 					w.WriteHeader(401)
 				}
-			} else {
+			case "/api/v4/projects/my_group/my_project":
+				if r.Header["Authorization"][0] == authHeader {
+					w.WriteHeader(200)
+					w.Write([]byte(projectInfo))
+				} else {
+					w.WriteHeader(401)
+				}
+			case "/api/v4/projects/my_group/my_archived_project":
+				if r.Header["Authorization"][0] == authHeader {
+					w.WriteHeader(200)
+					w.Write([]byte(archivedProjectInfo))
+				} else {
+					w.WriteHeader(401)
+				}
+			case "/api/v4/projects/my_profile/my_personal_project":
+				if r.Header["Authorization"][0] == authHeader {
+					w.WriteHeader(200)
+					w.Write([]byte(personalProjectInfo))
+				} else {
+					w.WriteHeader(401)
+				}
+			case "/api/v4/projects/my_group/my_bad_project":
+				w.WriteHeader(403)
+			default:
 				w.WriteHeader(404)
 			}
 		}))
 }
 
-func TestGitLabProviderBadToken(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+var _ = Describe("Gitlab Provider Tests", func() {
+	var p *GitLabProvider
+	var b *httptest.Server
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
+	BeforeEach(func() {
+		b = testGitLabBackend()
 
-	session := &sessions.SessionState{AccessToken: "unexpected_gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.Error(t, err)
-}
+		bURL, err := url.Parse(b.URL)
+		Expect(err).To(BeNil())
 
-func TestGitLabProviderUnverifiedEmailDenied(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+		p = testGitLabProvider(bURL.Host)
+	})
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
+	AfterEach(func() {
+		b.Close()
+	})
 
-	session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.Error(t, err)
-}
+	Context("with bad token", func() {
+		It("should trigger an error", func() {
+			p.AllowUnverifiedEmail = false
+			session := &sessions.SessionState{AccessToken: "unexpected_gitlab_access_token"}
+			err := p.EnrichSession(context.Background(), session)
+			Expect(err).To(MatchError(errors.New("failed to retrieve user info: error getting user info: unexpected status \"401\": ")))
+		})
+	})
 
-func TestGitLabProviderUnverifiedEmailAllowed(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+	Context("when filtering on email", func() {
+		type emailsTableInput struct {
+			expectedError        error
+			expectedValue        string
+			allowUnverifiedEmail bool
+		}
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
-	p.AllowUnverifiedEmail = true
+		DescribeTable("should return expected results",
+			func(in emailsTableInput) {
+				p.AllowUnverifiedEmail = in.allowUnverifiedEmail
+				session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
 
-	session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.NoError(t, err)
-	assert.Equal(t, "foo@bar.com", session.Email)
-}
+				err := p.EnrichSession(context.Background(), session)
 
-func TestGitLabProviderUsername(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+				if in.expectedError != nil {
+					Expect(err).To(MatchError(err))
+				} else {
+					Expect(err).To(BeNil())
+					Expect(session.Email).To(Equal(in.expectedValue))
+				}
+			},
+			Entry("unverified email denied", emailsTableInput{
+				expectedError:        errors.New("user email is not verified"),
+				allowUnverifiedEmail: false,
+			}),
+			Entry("unverified email allowed", emailsTableInput{
+				expectedError:        nil,
+				expectedValue:        "foo@bar.com",
+				allowUnverifiedEmail: true,
+			}),
+		)
+	})
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
-	p.AllowUnverifiedEmail = true
+	Context("when filtering on gitlab entities (groups and projects)", func() {
+		type entitiesTableInput struct {
+			expectedValue []string
+			projects      []string
+			groups        []string
+		}
 
-	session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.NoError(t, err)
-	assert.Equal(t, "FooBar", session.User)
-}
+		DescribeTable("should return expected results",
+			func(in entitiesTableInput) {
+				p.AllowUnverifiedEmail = true
+				session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
 
-func TestGitLabProviderGroupMembershipValid(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+				err := p.AddProjects(in.projects)
+				Expect(err).To(BeNil())
+				p.SetProjectScope()
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
-	p.AllowUnverifiedEmail = true
-	p.Groups = []string{"foo"}
+				if len(in.groups) > 0 {
+					p.Groups = in.groups
+				}
 
-	session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.NoError(t, err)
-	assert.Equal(t, "FooBar", session.User)
-}
+				err = p.EnrichSession(context.Background(), session)
 
-func TestGitLabProviderGroupMembershipMissing(t *testing.T) {
-	b := testGitLabBackend()
-	defer b.Close()
+				Expect(err).To(BeNil())
+				Expect(session.Groups).To(Equal(in.expectedValue))
+			},
+			Entry("project membership valid on group project", entitiesTableInput{
+				expectedValue: []string{"project:my_group/my_project"},
+				projects:      []string{"my_group/my_project"},
+			}),
+			Entry("project membership invalid on group project, insufficient access level level", entitiesTableInput{
+				expectedValue: nil,
+				projects:      []string{"my_group/my_project=40"},
+			}),
+			Entry("project membership valid on personnal project", entitiesTableInput{
+				expectedValue: []string{"project:my_profile/my_personal_project"},
+				projects:      []string{"my_profile/my_personal_project"},
+			}),
+			Entry("project membership invalid on personnal project, insufficient access level", entitiesTableInput{
+				expectedValue: nil,
+				projects:      []string{"my_profile/my_personal_project=40"},
+			}),
+			Entry("project membership invalid", entitiesTableInput{
+				expectedValue: nil,
+				projects:      []string{"my_group/my_bad_project"},
+			}),
+			Entry("group membership valid", entitiesTableInput{
+				expectedValue: []string{"group:foo"},
+				groups:        []string{"foo"},
+			}),
+			Entry("groups and projects", entitiesTableInput{
+				expectedValue: []string{"group:foo", "group:baz", "project:my_group/my_project", "project:my_profile/my_personal_project"},
+				groups:        []string{"foo", "baz"},
+				projects:      []string{"my_group/my_project", "my_profile/my_personal_project"},
+			}),
+			Entry("archived projects", entitiesTableInput{
+				expectedValue: nil,
+				groups:        []string{},
+				projects:      []string{"my_group/my_archived_project"},
+			}),
+		)
 
-	bURL, _ := url.Parse(b.URL)
-	p := testGitLabProvider(bURL.Host)
-	p.AllowUnverifiedEmail = true
-	p.Groups = []string{"baz"}
+	})
 
-	session := &sessions.SessionState{AccessToken: "gitlab_access_token"}
-	err := p.EnrichSession(context.Background(), session)
-	assert.Error(t, err)
-}
+	Context("when generating group list from multiple kind", func() {
+		type entitiesTableInput struct {
+			projects []string
+			groups   []string
+		}
+
+		DescribeTable("should prefix entities with group kind", func(in entitiesTableInput) {
+			p.Groups = in.groups
+			err := p.AddProjects(in.projects)
+			Expect(err).To(BeNil())
+
+			all := p.PrefixAllowedGroups()
+
+			Expect(len(all)).To(Equal(len(in.projects) + len(in.groups)))
+		},
+			Entry("simple test case", entitiesTableInput{
+				projects: []string{"my_group/my_project", "my_group/my_other_project"},
+				groups:   []string{"mygroup", "myothergroup"},
+			}),
+			Entry("projects only", entitiesTableInput{
+				projects: []string{"my_group/my_project", "my_group/my_other_project"},
+				groups:   []string{},
+			}),
+			Entry("groups only", entitiesTableInput{
+				projects: []string{},
+				groups:   []string{"mygroup", "myothergroup"},
+			}),
+		)
+	})
+})
diff --git a/providers/providers_suite_test.go b/providers/providers_suite_test.go
new file mode 100644
index 00000000..6a7f8b1f
--- /dev/null
+++ b/providers/providers_suite_test.go
@@ -0,0 +1,16 @@
+package providers_test
+
+import (
+	"testing"
+
+	"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestProviderSuite(t *testing.T) {
+	logger.SetOutput(GinkgoWriter)
+
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Providers")
+}