Translations: "Dutch" |
A multi dev container setup to support OAuth 2.0 authentication
Introduction
Over the last few days, Docker was messing up my local dev environment, so I switched to Orb Stack. While rebuilding my devcontainer, I saw some room for improvements and thought, why not share it? In this guide, you'll learn how to set up a secure local development environment that properly handles OAuth 2.0 authentication while maintaining a smooth development workflow.
Prerequisites
Before we begin, make sure you have the following installed:
- Visual Studio Code with the Dev Containers extension
- OrbStack / Docker Desktop or equivalent
- OpenSSL (for generating self-signed certificates)
- Git
The setup and the challenge
I mostly work on our portal where all pages/routes need authentication handled by our remote Identity server. Without a valid token, the pages won't be accessible and you can't query the API for content. We leverage OAuth 2 for this, and while there might be other approaches, creating backdoors for local development didn't feel right. By implementing the same authentication and authorization structure in your local development environment, you'll never be surprised when the flow isn't working correctly. So when working on a page within the portal, I, as the developer, also first must authenticate and get authorized just like in production.
Next to that, when I started with this project I checked out some packages on PyPI that could help with authentication, but most were lacking proper PKCE integration or were created specifically for platforms like Okta and Entra. So I decided, the best way to learn and truly understand what you're working with is to create it yourself. Meaning I needed to mock my local developer environment just like it would run in production.
Our portal is built on the Python Flask framework, so we're using a devcontainer running Python. We host the application in Azure App Services, which handles our certificates and inbound traffic. This means we don't have to worry about certificate renewal or handling HTTPS traffic.
This is an important consideration - I found Flask's built-in HTTPS implementation not compelling to use. So my choice is to run Flask on HTTP and on a high port. And since we're letting Azure App Service handle the routing and certificates in production, this left me with a challenge in the local environment: I needed a container that handles the certificates and can be used as the callback URL for authentication.
This was necessary because, if I need to access / test a route that is behind protected route (a route that needs a user to be authenticated) we must pass the OAuth2 with PKCE flow.
OAuth2 with PKCE flow
Before we dive into the devcontainer, a bit of essential information about OAuth. OAuth 2.0 with PKCE is an authorization framework that enables applications to securely obtain limited access to user accounts on remote services. Here's how it works:
- An application generates a unique code verifier and its corresponding code challenge
- When redirecting users to the identity provider, your app includes this challenge, adding protection against interception attacks
- After users authenticate on the authentication server and grant permissions, the identity provider redirects back with an authorization code
- The application exchanges this code along with the original code verifier for an access token through a secure backend request
This PKCE enhancement prevents malicious actors from intercepting and using the authorization code, making it particularly suitable for native and single-page applications. The resulting access token can then be used to make authenticated API calls on behalf of the user.
The redirect back (step 3) is the crucial step. The callback URL must be known to the identity provider, and your local system needs to listen and accept traffic for that particular hostname. I don't like messing with the host file regularly, so we created a URL that can be used on the local system (dev.{domain.tld}).
Overall overview of the auth flow:
flowchart TD
%% Define main containers
subgraph LocalDev["`**Local-Development-Environment**`"]
Browser["Browser dev.domain.tld"]
subgraph Containers["`**Docker Containers**`"]
direction LR
Nginx["Nginx Container Port 443 with SSL"]
Flask["Flask App Container Port 5001"]
end
end
subgraph Remote["`**Remote Services**`"]
IdServer["Identity Server OAuth 2.0 + PKCE"]
ApiService["API Services"]
end
%% Define connections
Browser --> |"1.HTTPS Request"| Nginx
Nginx --> |"2.HTTP Forward"| Flask
Flask --> |"3.Login Redirect"| IdServer
IdServer --> |"4.Authorization Code"| Browser
Browser --> |"5.Auth Code"| Nginx
Nginx --> |"6.Forward Auth"| Flask
Flask --> |"7.Request Token"| Nginx
Nginx --> |"8.Forward Token Request"| IdServer
IdServer --> |"9.Access Token"| Nginx
Nginx --> |"10.Forward Token"| Flask
Flask --> |"11.API Request"| ApiService
%% Styling
classDef browser fill:#2ecc71,stroke:#27ae60,color:#fff,font-weight:bold
classDef container fill:#2c3e50,stroke:#3498db,color:#fff,font-weight:bold
classDef remote fill:#8e44ad,stroke:#9b59b6,color:#fff,font-weight:bold
class Browser browser
class Nginx,Flask container
class IdServer,ApiService remote
%% Container styling with white text
style Containers fill:#1a1a1a,stroke:#666,stroke-width:2px,stroke-dasharray:5 5,color:#fff
style LocalDev fill:#1a1a1a,stroke:#666,stroke-width:2px,stroke-dasharray:5 5,color:#fff
style Remote fill:#1a1a1a,stroke:#666,stroke-width:2px,stroke-dasharray:5 5,color:#fff
%% Link styling for better visibility
linkStyle default stroke-width:2px,stroke:#666
The configuration files
Let's get to it. We need to create two containers: one for Flask development and one for nginx. Visual Studio Code will be attached to the Flask development devcontainer, and nginx will listen on port 443 and route traffic to the Flask development server in the devcontainer.
Creating Self-Signed certificates
Before setting up the containers, you'll need to generate self-signed certificates for local HTTPS. Here's how to do it:
1# Create a directory for certificates
2mkdir certs
3cd certs
4
5# Generate self-signed certificate and key
6openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
7 -keyout server.key -out server.crt \
8 -subj "/CN=localhost"
9
10# Set appropriate permissions
11chmod 644 server.crt
12chmod 600 server.key
The Devcontainer
For the devcontainer, I chose to keep it as light as possible. All Python packages will be (re)installed every time I start the development server in the container. This ensures that I always run the same package versions locally as will be installed during the build when pushing to the acceptance environment.
1FROM mcr.microsoft.com/devcontainers/python:3.12
2
3RUN apt-get update && apt-get install npm -yy
4
5RUN mkdir /workspace
6WORKDIR /workspace
7COPY ./requirements.txt ./requirements.txt
8
9EXPOSE 5001
Nginx container
The nginx container handles the SSL termination and routing. Here's the nginx.conf file that handles routing to the devcontainer:
server {
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
location / {
proxy_pass http://app:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The app hostname in the proxy_pass is resolved through Docker's internal DNS, which we'll configure in the docker-compose file.
The Dockerfile for the nginx container:
1FROM nginx:alpine
2COPY nginx.conf /etc/nginx/conf.d/default.conf
3COPY ./certs/server.crt /etc/nginx/ssl/server.crt
4COPY ./certs/server.key /etc/nginx/ssl/server.key
5ENTRYPOINT ["nginx", "-g", "daemon off;"]
Docker-compose.yml
Since our dev environment contains multiple containers that need to interact with each other, docker-compose.yml helps us accomplish this:
1version: '3.8'
2services:
3 app:
4 build:
5 context: .
6 dockerfile: ./Dockerfile
7 image: "flask-oauth-dev:latest"
8 restart: on-failure
9 volumes:
10 - .:/workspace
11 ports:
12 - "5001:5001"
13 command: sleep infinity
14 networks:
15 - internalNetwork
16 nginx:
17 build:
18 context: .
19 dockerfile: Dockerfile.nginx
20 restart: on-failure
21 ports:
22 - "443:443"
23 depends_on:
24 - app
25 networks:
26 - internalNetwork
27networks:
28 internalNetwork:
Key points in this compose file:
- We define two services (containers)
- Both containers are connected to the
internalNetwork, allowing them to communicate using their service names as hostnames - The
portssection exposes internal ports to external ports - The volume mount uses a relative path (
.:/workspace) for better portability
devcontainer.json
Finally, here's the devcontainer.json that starts the containers and connects VS Code to the devcontainer:
1{
2 "name": "backend",
3 "mounts": [
4 "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
5 ],
6 "dockerComposeFile": "./docker-compose.yml",
7 "service": "app",
8
9 "workspaceFolder": "/workspace/",
10
11 "shutdownAction": "stopCompose",
12
13 "postCreateCommand": [
14 "python -m venv /workspace/.venv && source /workspace/.venv/bin/activate && pip install -r ./requirements.txt"
15 ],
16 "customizations": {
17 "vscode": {
18 "extensions": [
19 ]
20 }
21 }
22}
Important configurations in this file:
mounts- mounts the local .ssh folder into the containerdockerComposeFile- references the docker-compose fileservice- tells VS Code which service (container) to attach toshutdownAction- stops the containers when VS Code stopspostCreateCommand- creates and activates a virtual environment in the workspace directory
Troubleshooting tips
Here are some common issues you might encounter and how to resolve them:
Certificate Issues
- Make sure your certificates are properly generated and have the correct permissions
- Check that the certificate paths in the nginx configuration match your actual file locations
Connection Refused
- Verify that both containers are running (
docker-compose ps) - Check if the Flask application is listening on all interfaces (0.0.0.0) and the correct port
- Ensure the internal network is properly configured
- Verify that both containers are running (
OAuth Callback Problems
- Confirm that your callback URL is properly registered with your identity provider
- Check that the nginx configuration is properly forwarding the callback path
Wrapping up
And there you have it! A solid local development setup that mirrors your production environment's authentication flow. No more worrying about certificate management or authentication backdoors.
I've been using this setup for more than a year now, and it's made my development workflow so much smoother. The best part? When you push your changes to acceptance or production, you can be confident that the authentication flow will work exactly as it did on your local machine.
A few quick tips from my experience:
- Keep your certificates in a separate folder and add them to your .gitignore
- If you're working with a team, share the certificate generation steps in your project documentation
- Consider creating a simple script to set up the entire environment in one go
If you run into any issues or have ideas for improving this setup, I'd love to hear about them! Feel free to drop a comment below or create an issue in the repository.
Next up, I might share how we handle automatic token refresh and session management in this setup - so stay tuned if you're interested in that! Or how to secure your routes with autehntication and authorization.