diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index daccf8e..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,79 +0,0 @@ -# Common Logic -machine: &machine-cfg - image: ubuntu-2004:202107-02 - -defaults: &defaults - steps: - - attach_workspace: - at: ~/ - - run: - name: Prepare environment variables - command: | - cd $AUTH0_CFG - mv .env.example .env - sed -i 's|{DOMAIN}|'$auth0_domain'|g' .env - sed -i 's|{API_IDENTIFIER}|'$api_identifier'|g' .env - - run: - name: Background Server - command: cd $AUTH0_CFG && sh exec.sh - background: true - - run: - name: Wait until server is online - command: | - until $(curl --silent --head --output /dev/null --fail http://localhost:3010/api/public); do - sleep 5 - done - - run: - name: Prepare tests - command: | - cd test - echo "AUTH0_DOMAIN=$auth0_domain" >> .env - echo "API_IDENTIFIER=$api_identifier" >> .env - echo "AUTH0_CLIENT_ID_1=$client_id_scopes_none" >> .env - echo "AUTH0_CLIENT_SECRET_1=$client_secret_scopes_none" >> .env - echo "AUTH0_CLIENT_ID_2=$client_id_scopes_read" >> .env - echo "AUTH0_CLIENT_SECRET_2=$client_secret_scopes_read" >> .env - echo "AUTH0_CLIENT_ID_3=$client_id_scopes_write" >> .env - echo "AUTH0_CLIENT_SECRET_3=$client_secret_scopes_write" >> .env - echo "AUTH0_CLIENT_ID_4=$client_id_scopes_readwrite" >> .env - echo "AUTH0_CLIENT_SECRET_4=$client_secret_scopes_readwrite" >> .env - echo "API_URL=http://localhost:3010" >> .env - npm install - - run: - name: Execute automated tests - command: cd test && npm test - -# Jobs and Workflows -version: 2 -jobs: - checkout: - machine: - <<: *machine-cfg - steps: - - checkout - - run: - name: Clone test script - command: git clone -b v0.0.1 --depth 1 https://github.com/auth0-samples/api-quickstarts-tests test - - persist_to_workspace: - root: ~/ - paths: - - project - - test - 01-Authorization-RS256: - machine: - <<: *machine-cfg - environment: - AUTH0_CFG: 01-Authorization-RS256 - SAMPLE_PATH: 01-Authorization-RS256 - <<: *defaults - -workflows: - version: 2 - API-Tests: - jobs: - - checkout: - context: Quickstart API Tests - - 01-Authorization-RS256: - context: Quickstart API Tests - requires: - - checkout diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..17a0f8a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ + + +### 🔧 Changes + + + +### 📚 References + + + +### 🔬 Testing + + + +### 📝 Checklist + +- [ ] All new/changed/fixed functionality is covered by tests (or N/A) +- [ ] I have added documentation for all new/changed functionality (or N/A) + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 63eafc1..5e85025 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,19 @@ version: 2 updates: + - package-ecosystem: "gomod" + directory: "/01-Authorization-RS256" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: + ["version-update:semver-major", "version-update:semver-patch"] - - package-ecosystem: "gomod" - directory: "/01-Authorization-RS256" + - package-ecosystem: "gomod" + directory: "/01-Quickstart-Go-API" schedule: interval: "daily" ignore: - dependency-name: "*" - update-types: ["version-update:semver-major", "version-update:semver-patch"] + update-types: + ["version-update:semver-major", "version-update:semver-patch"] diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml deleted file mode 100644 index 916745e..0000000 --- a/.github/workflows/semgrep.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Semgrep - -on: - pull_request: {} - - push: - branches: ["master", "main"] - - schedule: - - cron: '30 0 1,15 * *' - -jobs: - semgrep: - name: Scan - runs-on: ubuntu-latest - container: - image: returntocorp/semgrep - # Skip any PR created by dependabot to avoid permission issues - if: (github.actor != 'dependabot[bot]') - steps: - - uses: actions/checkout@v3 - - - run: semgrep ci - env: - SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a524bd2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,170 @@ +name: API Tests + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test-01-authorization-rs256: + runs-on: ubuntu-latest + + env: + AUTH0_CFG: 01-Authorization-RS256 + SAMPLE_PATH: 01-Authorization-RS256 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.19" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Clone test scripts + run: git clone --depth 1 https://github.com/auth0-samples/api-quickstarts-tests test + + - name: Prepare environment variables + env: + AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} + API_IDENTIFIER: ${{ secrets.API_IDENTIFIER }} + run: | + cd $AUTH0_CFG + mv .env.example .env + sed -i 's|{DOMAIN}|'$AUTH0_DOMAIN'|g' .env + sed -i 's|{API_IDENTIFIER}|'$API_IDENTIFIER'|g' .env + + - name: Install Go dependencies + run: cd $AUTH0_CFG && go mod download + + - name: Build Docker image + run: cd $AUTH0_CFG && docker build -t auth0-golang-api . + + - name: Start server in detached mode + run: cd $AUTH0_CFG && docker run -d --env-file .env -p 3010:3010 auth0-golang-api + + - name: Wait for server to be ready + run: | + sleep 3 + until $(curl --silent --head --output /dev/null --fail http://localhost:3010/api/public); do + echo "Waiting for server to start..." + sleep 5 + done + + - name: Prepare test environment + env: + AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} + API_IDENTIFIER: ${{ secrets.API_IDENTIFIER }} + CLIENT_ID_SCOPES_NONE: ${{ secrets.CLIENT_ID_SCOPES_NONE }} + CLIENT_SECRET_SCOPES_NONE: ${{ secrets.CLIENT_SECRET_SCOPES_NONE }} + CLIENT_ID_SCOPES_READ: ${{ secrets.CLIENT_ID_SCOPES_READ }} + CLIENT_SECRET_SCOPES_READ: ${{ secrets.CLIENT_SECRET_SCOPES_READ }} + CLIENT_ID_SCOPES_WRITE: ${{ secrets.CLIENT_ID_SCOPES_WRITE }} + CLIENT_SECRET_SCOPES_WRITE: ${{ secrets.CLIENT_SECRET_SCOPES_WRITE }} + CLIENT_ID_SCOPES_READWRITE: ${{ secrets.CLIENT_ID_SCOPES_READWRITE }} + CLIENT_SECRET_SCOPES_READWRITE: ${{ secrets.CLIENT_SECRET_SCOPES_READWRITE }} + run: | + cd test + echo "AUTH0_DOMAIN=$AUTH0_DOMAIN" >> .env + echo "API_IDENTIFIER=$API_IDENTIFIER" >> .env + echo "AUTH0_CLIENT_ID_1=$CLIENT_ID_SCOPES_NONE" >> .env + echo "AUTH0_CLIENT_SECRET_1=$CLIENT_SECRET_SCOPES_NONE" >> .env + echo "AUTH0_CLIENT_ID_2=$CLIENT_ID_SCOPES_READ" >> .env + echo "AUTH0_CLIENT_SECRET_2=$CLIENT_SECRET_SCOPES_READ" >> .env + echo "AUTH0_CLIENT_ID_3=$CLIENT_ID_SCOPES_WRITE" >> .env + echo "AUTH0_CLIENT_SECRET_3=$CLIENT_SECRET_SCOPES_WRITE" >> .env + echo "AUTH0_CLIENT_ID_4=$CLIENT_ID_SCOPES_READWRITE" >> .env + echo "AUTH0_CLIENT_SECRET_4=$CLIENT_SECRET_SCOPES_READWRITE" >> .env + echo "API_URL=http://localhost:3010" >> .env + npm install + + - name: Run automated tests + run: cd test && npm test + + test-01-quickstart-go-api: + runs-on: ubuntu-latest + + env: + AUTH0_CFG: 01-Quickstart-Go-API + SAMPLE_PATH: 01-Quickstart-Go-API + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Clone test scripts + run: git clone --depth 1 https://github.com/auth0-samples/api-quickstarts-tests test + + - name: Prepare environment variables + env: + AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} + API_IDENTIFIER: ${{ secrets.API_IDENTIFIER }} + run: | + cd $AUTH0_CFG + mv .env.example .env + sed -i 's|{DOMAIN}|'$AUTH0_DOMAIN'|g' .env + sed -i 's|{API_IDENTIFIER}|'$API_IDENTIFIER'|g' .env + + - name: Install Go dependencies + run: cd $AUTH0_CFG && go mod download + + - name: Build Docker image + run: cd $AUTH0_CFG && docker build -t auth0-golang-api . + + - name: Start server in detached mode + run: cd $AUTH0_CFG && docker run -d --env-file .env -p 8080:8080 auth0-golang-api + + - name: Wait for server to be ready + run: | + sleep 3 + until $(curl --silent --head --output /dev/null --fail http://localhost:8080/api/public); do + echo "Waiting for server to start..." + sleep 5 + done + + - name: Prepare test environment + env: + AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} + API_IDENTIFIER: ${{ secrets.API_IDENTIFIER }} + CLIENT_ID_SCOPES_NONE: ${{ secrets.CLIENT_ID_SCOPES_NONE }} + CLIENT_SECRET_SCOPES_NONE: ${{ secrets.CLIENT_SECRET_SCOPES_NONE }} + CLIENT_ID_SCOPES_READ: ${{ secrets.CLIENT_ID_SCOPES_READ }} + CLIENT_SECRET_SCOPES_READ: ${{ secrets.CLIENT_SECRET_SCOPES_READ }} + CLIENT_ID_SCOPES_WRITE: ${{ secrets.CLIENT_ID_SCOPES_WRITE }} + CLIENT_SECRET_SCOPES_WRITE: ${{ secrets.CLIENT_SECRET_SCOPES_WRITE }} + CLIENT_ID_SCOPES_READWRITE: ${{ secrets.CLIENT_ID_SCOPES_READWRITE }} + CLIENT_SECRET_SCOPES_READWRITE: ${{ secrets.CLIENT_SECRET_SCOPES_READWRITE }} + run: | + cd test + echo "AUTH0_DOMAIN=$AUTH0_DOMAIN" >> .env + echo "API_IDENTIFIER=$API_IDENTIFIER" >> .env + echo "AUTH0_CLIENT_ID_1=$CLIENT_ID_SCOPES_NONE" >> .env + echo "AUTH0_CLIENT_SECRET_1=$CLIENT_SECRET_SCOPES_NONE" >> .env + echo "AUTH0_CLIENT_ID_2=$CLIENT_ID_SCOPES_READ" >> .env + echo "AUTH0_CLIENT_SECRET_2=$CLIENT_SECRET_SCOPES_READ" >> .env + echo "AUTH0_CLIENT_ID_3=$CLIENT_ID_SCOPES_WRITE" >> .env + echo "AUTH0_CLIENT_SECRET_3=$CLIENT_SECRET_SCOPES_WRITE" >> .env + echo "AUTH0_CLIENT_ID_4=$CLIENT_ID_SCOPES_READWRITE" >> .env + echo "AUTH0_CLIENT_SECRET_4=$CLIENT_SECRET_SCOPES_READWRITE" >> .env + echo "API_URL=http://localhost:8080" >> .env + npm install + + - name: Run automated tests + run: cd test && npm test diff --git a/.gitignore b/.gitignore index ed3bb2a..60ed564 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store 01-Authorization-RS256/.env 01-Authorization-RS256/vendor/ +01-Quickstart-Go-API/.env +01-Quickstart-Go-API/vendor/ diff --git a/01-Quickstart-Go-API/.dockerignore b/01-Quickstart-Go-API/.dockerignore new file mode 100644 index 0000000..d3c9987 --- /dev/null +++ b/01-Quickstart-Go-API/.dockerignore @@ -0,0 +1,3 @@ +README.md +exec.sh +exec.ps1 diff --git a/01-Quickstart-Go-API/.env.example b/01-Quickstart-Go-API/.env.example new file mode 100644 index 0000000..6b907e4 --- /dev/null +++ b/01-Quickstart-Go-API/.env.example @@ -0,0 +1,2 @@ +AUTH0_DOMAIN={DOMAIN} +AUTH0_AUDIENCE={API_IDENTIFIER} diff --git a/01-Quickstart-Go-API/Dockerfile b/01-Quickstart-Go-API/Dockerfile new file mode 100644 index 0000000..231524c --- /dev/null +++ b/01-Quickstart-Go-API/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.24-alpine + +# Install git +RUN apk --update add \ + git openssl \ + && rm /var/cache/apk/* + +# Define current working directory +WORKDIR /01-Quickstart-Go-API + +# Download modules to local cache so we can skip re- +# downloading on consecutive docker build commands +COPY go.mod . +COPY go.sum . +RUN go mod download + +# Add sources +COPY . . + +# Expose port 8080 for our api binary +EXPOSE 8080 + +CMD ["go", "run", "cmd/server/main.go"] diff --git a/01-Quickstart-Go-API/README.md b/01-Quickstart-Go-API/README.md new file mode 100644 index 0000000..87fac10 --- /dev/null +++ b/01-Quickstart-Go-API/README.md @@ -0,0 +1,168 @@ +# Auth0 Golang API Sample - Authorization (RS256) + +This sample demonstrates how to protect a Go API using Auth0 with RS256 signed JWT tokens. + +## Prerequisites + +- Go 1.24 or higher +- An Auth0 account ([Sign up for free](https://auth0.com/signup)) + +## Setup + +1. **Create an API in Auth0 Dashboard:** + - Go to APIs in your Auth0 Dashboard + - Click "Create API" + - Give it a name and identifier (audience) + - Select RS256 signing algorithm + +2. **Add permissions to your API:** + - Select your API in the Auth0 Dashboard + - Go to the **Permissions** tab + - Add a new permission: + - **Permission (Scope):** `read:messages` + - **Description:** Read messages from the API + - Click **Add** + +3. **Enable RBAC for your API (Important!):** + - Select your API in the Auth0 Dashboard + - Go to the **Settings** tab + - Scroll down to **RBAC Settings** + - Enable **Enable RBAC** + - Enable **Add Permissions in the Access Token** + - Click **Save** + + Enable RBAC Settings + + > ⚠️ **Note:** Without enabling these settings, the `permissions` array in your access token will be empty, causing the scoped endpoint to fail with a 403 error. + +4. **Configure Machine-to-Machine Application (for testing):** + - Go to **Applications > Applications** in your Auth0 Dashboard + - Select your Machine-to-Machine application (or create one) + - Go to the **APIs** tab + - Find your API and expand it + - Toggle **Authorize** to enable it + - Select the permissions you want to grant (e.g., `read:messages`) + - Click **Update** + + Configure M2M Permissions + + > 💡 This step ensures that tokens generated for this application will include the selected permissions in the `permissions` array. + +5. **Configure environment variables:** + ```bash + cp .env.example .env + ``` + + Edit `.env` and set: + ``` + AUTH0_DOMAIN=your-tenant.auth0.com + AUTH0_AUDIENCE=https://your-api-identifier + ``` + +6. **Install dependencies:** + ```bash + go mod download + ``` + +## Running the Sample + +```bash +go run cmd/server/main.go +``` + +The server will start on port 8080. + +## API Endpoints + +### Public Endpoint (No Authentication Required) +```bash +curl http://localhost:8080/api/public +``` + +### Private Endpoint (Requires Valid JWT) +```bash +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + http://localhost:8080/api/private +``` + +### Scoped Endpoint (Requires JWT with `read:messages` scope) +```bash +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + http://localhost:8080/api/private-scoped +``` + +## Getting an Access Token + +To test the protected endpoints, you need an access token: + +1. Go to the **Test** tab of your API in the Auth0 Dashboard +2. In the **Scopes** field, add `read:messages` (for testing the scoped endpoint) +3. Copy the provided access token or `curl` command +4. Use it in the `Authorization` header as shown above + +**Note:** Tokens without the `read:messages` scope will receive a 403 Forbidden error when accessing the scoped endpoint. + +### Verifying Your Token Contains Permissions + +You can decode your JWT token at [jwt.io](https://jwt.io) to verify it contains the permissions. A properly configured token should include: + +```json +{ + "iss": "https://your-tenant.auth0.com/", + "sub": "...", + "aud": "https://your-api-identifier", + "iat": 1697988893, + "exp": 1698075293, + "azp": "...", + "scope": "read:messages", + "gty": "client-credentials", + "permissions": [ + "read:messages" + ] +} +``` + +If the `permissions` array is empty, please verify you've completed steps 3 and 4 in the Setup section above. + +## Project Structure + +``` +01-Quickstart-Go-API/ +├── cmd/ +│ └── server/ +│ └── main.go # Application entry point +├── internal/ +│ ├── auth/ +│ │ ├── validator.go # JWT validator setup +│ │ └── middleware.go # JWT middleware +│ ├── config/ +│ │ └── auth.go # Configuration loading +│ └── handlers/ +│ └── api.go # HTTP handlers +├── .env.example +├── go.mod +└── README.md +``` + +## Key Features + +- ✅ RS256 JWT validation +- ✅ Custom claims support +- ✅ Scope-based authorization +- ✅ Graceful shutdown +- ✅ Production-ready timeouts +- ✅ Structured logging with slog + +## Learn More + +- [Auth0 Go SDK Documentation](https://github.com/auth0/go-jwt-middleware) +- [Auth0 Documentation](https://auth0.com/docs) +- [Securing Go APIs with +## Project Structure + +``` +01-Autckstart/backend/golang) + +## License + +This project is licensed under the MIT license. See the [LICENSE](../../LICENSE) file for more info. diff --git a/01-Quickstart-Go-API/cmd/server/main.go b/01-Quickstart-Go-API/cmd/server/main.go new file mode 100644 index 0000000..44a4abd --- /dev/null +++ b/01-Quickstart-Go-API/cmd/server/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "time" + + "github.com/auth0-samples/auth0-golang-api-samples/01-Quickstart-Go-API/internal/auth" + "github.com/auth0-samples/auth0-golang-api-samples/01-Quickstart-Go-API/internal/config" + "github.com/auth0-samples/auth0-golang-api-samples/01-Quickstart-Go-API/internal/handlers" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables") + } + + cfg, err := config.LoadAuthConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + jwtValidator, err := auth.NewValidator(cfg.Domain, cfg.Audience) + if err != nil { + log.Fatalf("Failed to create validator: %v", err) + } + + middleware, err := auth.NewMiddleware(jwtValidator) + if err != nil { + log.Fatalf("Failed to create middleware: %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/public", handlers.PublicHandler) + mux.Handle("/api/private", middleware.CheckJWT(http.HandlerFunc(handlers.PrivateHandler))) + mux.Handle("/api/private-scoped", middleware.CheckJWT(http.HandlerFunc(handlers.ScopedHandler))) + + srv := &http.Server{ + Addr: ":8080", + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Println("Server starting on :8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + <-quit + + log.Println("Shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/01-Quickstart-Go-API/exec.ps1 b/01-Quickstart-Go-API/exec.ps1 new file mode 100644 index 0000000..5a9854a --- /dev/null +++ b/01-Quickstart-Go-API/exec.ps1 @@ -0,0 +1,2 @@ +docker build -t auth0-golang-api . +docker run --env-file .env -p 8080:8080 -it auth0-golang-api diff --git a/01-Quickstart-Go-API/exec.sh b/01-Quickstart-Go-API/exec.sh new file mode 100644 index 0000000..49bc7a4 --- /dev/null +++ b/01-Quickstart-Go-API/exec.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +docker build -t auth0-golang-api . +docker run --env-file .env -p 8080:8080 -it auth0-golang-api diff --git a/01-Quickstart-Go-API/go.mod b/01-Quickstart-Go-API/go.mod new file mode 100644 index 0000000..d6ce969 --- /dev/null +++ b/01-Quickstart-Go-API/go.mod @@ -0,0 +1,24 @@ +module github.com/auth0-samples/auth0-golang-api-samples/01-Quickstart-Go-API + +go 1.24.0 + +require ( + github.com/auth0/go-jwt-middleware/v3 v3.0.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.3 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.12 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/01-Quickstart-Go-API/go.sum b/01-Quickstart-Go-API/go.sum new file mode 100644 index 0000000..6f8d5ca --- /dev/null +++ b/01-Quickstart-Go-API/go.sum @@ -0,0 +1,45 @@ +github.com/auth0/go-jwt-middleware/v3 v3.0.0 h1:+rvUPCT+VbAuK4tpS13fWfZrMyqTwLopt3VoY0Y7kvA= +github.com/auth0/go-jwt-middleware/v3 v3.0.0/go.mod h1:iU42jqjRyeKbf9YYSnRnolr836gk6Ty/jnUNuVq2b0o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs= +github.com/lestrrat-go/httprc/v3 v3.0.3/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/01-Quickstart-Go-API/internal/auth/claims.go b/01-Quickstart-Go-API/internal/auth/claims.go new file mode 100644 index 0000000..6ab660f --- /dev/null +++ b/01-Quickstart-Go-API/internal/auth/claims.go @@ -0,0 +1,46 @@ +package auth + +import ( + "context" + "fmt" + "strings" +) + +// CustomClaims contains custom data we want to parse from the JWT. +type CustomClaims struct { + Scope string `json:"scope"` +} + +// Validate ensures the custom claims are properly formatted. +func (c *CustomClaims) Validate(ctx context.Context) error { + // Scope is optional, but if present, must be properly formatted + if c.Scope == "" { + return nil // No scope is valid - not all endpoints require permissions + } + + // Validate scope format (no leading/trailing spaces, no double spaces) + if strings.TrimSpace(c.Scope) != c.Scope { + return fmt.Errorf("scope claim has invalid whitespace") + } + + if strings.Contains(c.Scope, " ") { + return fmt.Errorf("scope claim contains double spaces") + } + + return nil +} + +// HasScope checks whether our claims have a specific scope. +func (c *CustomClaims) HasScope(expectedScope string) bool { + if c.Scope == "" { + return false + } + + scopes := strings.Split(c.Scope, " ") + for _, scope := range scopes { + if scope == expectedScope { + return true + } + } + return false +} diff --git a/01-Quickstart-Go-API/internal/auth/middleware.go b/01-Quickstart-Go-API/internal/auth/middleware.go new file mode 100644 index 0000000..4d754e2 --- /dev/null +++ b/01-Quickstart-Go-API/internal/auth/middleware.go @@ -0,0 +1,22 @@ +package auth + +import ( + "log/slog" + "net/http" + + jwtmiddleware "github.com/auth0/go-jwt-middleware/v3" + "github.com/auth0/go-jwt-middleware/v3/validator" +) + +func NewMiddleware(jwtValidator *validator.Validator) (*jwtmiddleware.JWTMiddleware, error) { + return jwtmiddleware.New( + jwtmiddleware.WithValidator(jwtValidator), + jwtmiddleware.WithValidateOnOptions(false), + jwtmiddleware.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) { + slog.Error("JWT validation failed", "error", err, "path", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"Failed to validate JWT."}`)) + }), + ) +} diff --git a/01-Quickstart-Go-API/internal/auth/validator.go b/01-Quickstart-Go-API/internal/auth/validator.go new file mode 100644 index 0000000..be304a2 --- /dev/null +++ b/01-Quickstart-Go-API/internal/auth/validator.go @@ -0,0 +1,41 @@ +package auth + +import ( + "fmt" + "net/url" + "time" + + "github.com/auth0/go-jwt-middleware/v3/jwks" + "github.com/auth0/go-jwt-middleware/v3/validator" +) + +func NewValidator(domain, audience string) (*validator.Validator, error) { + issuerURL, err := url.Parse("https://" + domain + "/") + if err != nil { + return nil, fmt.Errorf("failed to parse issuer URL: %w", err) + } + + provider, err := jwks.NewCachingProvider( + jwks.WithIssuerURL(issuerURL), + jwks.WithCacheTTL(5*time.Minute), + ) + if err != nil { + return nil, fmt.Errorf("failed to create JWKS provider: %w", err) + } + + jwtValidator, err := validator.New( + validator.WithKeyFunc(provider.KeyFunc), + validator.WithAlgorithm(validator.RS256), + validator.WithIssuer(issuerURL.String()), + validator.WithAudience(audience), + validator.WithCustomClaims(func() validator.CustomClaims { + return &CustomClaims{} + }), + validator.WithAllowedClockSkew(30*time.Second), + ) + if err != nil { + return nil, fmt.Errorf("failed to create validator: %w", err) + } + + return jwtValidator, nil +} diff --git a/01-Quickstart-Go-API/internal/config/auth.go b/01-Quickstart-Go-API/internal/config/auth.go new file mode 100644 index 0000000..290eb54 --- /dev/null +++ b/01-Quickstart-Go-API/internal/config/auth.go @@ -0,0 +1,28 @@ +package config + +import ( + "fmt" + "os" +) + +type AuthConfig struct { + Domain string + Audience string +} + +func LoadAuthConfig() (*AuthConfig, error) { + domain := os.Getenv("AUTH0_DOMAIN") + if domain == "" { + return nil, fmt.Errorf("AUTH0_DOMAIN environment variable required") + } + + audience := os.Getenv("AUTH0_AUDIENCE") + if audience == "" { + return nil, fmt.Errorf("AUTH0_AUDIENCE environment variable required") + } + + return &AuthConfig{ + Domain: domain, + Audience: audience, + }, nil +} diff --git a/01-Quickstart-Go-API/internal/handlers/api.go b/01-Quickstart-Go-API/internal/handlers/api.go new file mode 100644 index 0000000..52dd99d --- /dev/null +++ b/01-Quickstart-Go-API/internal/handlers/api.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/auth0-samples/auth0-golang-api-samples/01-Quickstart-Go-API/internal/auth" + jwtmiddleware "github.com/auth0/go-jwt-middleware/v3" + "github.com/auth0/go-jwt-middleware/v3/validator" +) + +func PublicHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]string{ + "message": "Hello from a public endpoint! You don't need to be authenticated to see this.", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func PrivateHandler(w http.ResponseWriter, r *http.Request) { + response := map[string]string{ + "message": "Hello from a private endpoint! You need to be authenticated to see this.", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func ScopedHandler(w http.ResponseWriter, r *http.Request) { + claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context()) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"Unauthorized."}`)) + return + } + + customClaims, ok := claims.CustomClaims.(*auth.CustomClaims) + if !ok || !customClaims.HasScope("read:messages") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message":"Insufficient scope."}`)) + return + } + + response := map[string]string{ + "message": "Hello from a private endpoint! You need to be authenticated and have a scope of read:messages to see this.", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/LICENSE b/LICENSE index 86bcdb8..71d9d2c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Auth0 Samples +Copyright (c) 2026 Auth0 Samples Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d2fda52..8acb5e2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Auth0 Golang API Samples -[![CircleCI](https://img.shields.io/circleci/project/github/auth0-samples/auth0-golang-api-samples.svg?style=flat-square)](https://circleci.com/gh/auth0-samples/auth0-golang-api-samples/tree/master) +[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/auth0-samples/auth0-golang-api-samples/test.yml?branch=master&style=flat-square)](https://github.com/auth0-samples/auth0-golang-api-samples/actions) These samples demonstrate how to create an API with Go which only permits access to resources if a valid **access token** is included. This verification is done by validating the signature and claims in a JSON Web Token (JWT) signed by Auth0. diff --git a/assets/enable-rbac-settings.png b/assets/enable-rbac-settings.png new file mode 100644 index 0000000..a5d5903 Binary files /dev/null and b/assets/enable-rbac-settings.png differ diff --git a/assets/m2m-permissions.png b/assets/m2m-permissions.png new file mode 100644 index 0000000..0325429 Binary files /dev/null and b/assets/m2m-permissions.png differ