{"id":16123721,"url":"https://github.com/austenstone/github-actions-oauth","last_synced_at":"2025-04-06T12:17:13.541Z","repository":{"id":166021063,"uuid":"641445381","full_name":"austenstone/github-actions-oauth","owner":"austenstone","description":"GitHub OAuth using GitHub Pages \u0026 Actions","archived":false,"fork":false,"pushed_at":"2023-08-17T15:00:28.000Z","size":103,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-02-12T17:53:33.350Z","etag":null,"topics":["github-oauth"],"latest_commit_sha":null,"homepage":"https://austenstone.github.io/github-actions-oauth/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/austenstone.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"github":["austenstone"]}},"created_at":"2023-05-16T13:36:34.000Z","updated_at":"2023-05-25T17:25:43.000Z","dependencies_parsed_at":null,"dependency_job_id":"3ddb07e2-7ef6-42bd-b342-a35ce38cd732","html_url":"https://github.com/austenstone/github-actions-oauth","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fgithub-actions-oauth","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fgithub-actions-oauth/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fgithub-actions-oauth/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/austenstone%2Fgithub-actions-oauth/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/austenstone","download_url":"https://codeload.github.com/austenstone/github-actions-oauth/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247478331,"owners_count":20945267,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["github-oauth"],"created_at":"2024-10-09T21:18:35.172Z","updated_at":"2025-04-06T12:17:13.425Z","avatar_url":"https://github.com/austenstone.png","language":"JavaScript","funding_links":["https://github.com/sponsors/austenstone"],"categories":[],"sub_categories":[],"readme":"# GitHub Pages OAuth using Actions\n\nI wanted to perform GitHub OAuth from GitHub Pages but we can't expose the App's client secret. This means we need a backend service to authenticate with. We could use lambda functions or just spin up a server but I wanted to use ONLY GitHub products.\n\nAnd so the idea was to run authentication within GitHub Actions!\n\n# Call a Workflow like a lambda function\nThe first step is to figure out how to run a workflow like a lambda function. \n\n## Permissions\n\nSo our first problem is that we need to authenticate to call the workflow API but Actions is our authentication.\n\n[Fine Grained PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token)s can scope permissions to ONLY a single repo with ONLY actions permissions. As long as we don't put anything sensetive in this repo it should be OK to share this PAT publicly.\n\nSo great we commit our fine grained PAT to the code in our repo but it immedietly goes to expired. This is GitHub Secret Scanning trying to protect you. To get around this we can base64 encode/decode our PAT.\n\n\u003e **Warning**\n\u003e This can technically be abused as this token could be extracted from the code and then used manually to lookup other user's workflow runs. These workflow runs contain the user token which should not be shared. This is why this solution should only be used in cases were this scenario is palletable. Let's say internally in a private repo.\n\n## `workflow_dispatch`\n\nWe have the [`workflow_dispatch`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch) as a starting point.\n\nLet's first start by creating a workflow file `api.yml`:\n```yml\nname: API\n\non:\n  workflow_dispatch:\n\njobs:\n  hello:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo Hello World!\n```\n\nNow we can dispatch this workflow using the API [`/repos/${OWNER}/${REPO}/actions/workflows/${WORKFLOW_ID}/dispatches`]()!\n\nThis is great but we have a huge issue. The run id used in subsequent requests is not returned to us!\n\n## Dealing with missing run id\n\nSo because we don't have any ID we will have to poll the API for the workflow. We can doing this using [`/repos/${OWNER}/${REPO}/actions/runs`]().\n\nTo filter our result even further we can use the created parameter. Let's say any workflows in the last 5 minutes.\n`/repos/${OWNER}/${REPO}/actions/runs?created=\u003e${new Date(Date.now() - 5 * 60 * 1000).toISOString()}`.\n\nThis will return all the workflows in the last five minutes but how do we know which ones are ours?!\n\n## Finding the associated workflow\n\nSo to find the associated workflow we're going to have to get cleaver. We need some unique identifier that can be retreived via the API.\n\nWe have the `jobs_url` in the response from the runs API but we can't see any inputs/outputs we passed in. Only the names of the steps and their conclusion.\n\nIt just so happens the name of a step can be variable so let's modify our workflow to put a unique identifier in the step name!\n\n```yml\nname: API\n\non:\n  workflow_dispatch:\n    inputs:\n      uid:\n        description: 'Unique ID for the request'\n        required: true\n\njobs:\n  login:\n    runs-on: ubuntu-latest\n    hello:\n      - name: ${{ inputs.uid }}\n        run: echo Hello World!\n```\n\nSo let's break down what we're doing so far:\n1. GET [`/repos/${OWNER}/${REPO}/actions/workflows/${WORKFLOW_ID}/dispatches`]() again but this time pass a unique id as the input `uid`.\n2. Poll [`/repos/${OWNER}/${REPO}/actions/runs?created=\u003e${new Date(Date.now() - 5 * 60 * 1000).toISOString()`]() to find new runs in the last five minutes.\n3. Using the response itterate the runs and call GET `jobs_url` from the run object.\n4. Using the response itterate the steps to find the one where `name === uid`\n\nAnd perfect we have a way to dispatch a workflow, get associated workflow, and wait for it to finish. But what about the data? How do we get the output?\n\n## Getting the workflow output\n\nSo now that we have the workflow run we need to get some output. The best way to do this is the log output. We can fetch the logs using [`/repos/${OWNER}/${REPO}/actions/runs/${RUN_ID}/logs`]().\n\nThis API will redirect us to a zip file. We can download this zip file and get our final output!\n\n# GitHub OAuth\n\nOkay so now we have Actions acting as our \"backend service\" so let's implement [OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app).\n\nStart by [creating a GitHub App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). Use the same redirect URL as the HTTP server where you are doing development. This could be localhost or GitHub Pages.\n\nWe need to direct the user to `/login/oauth/authorize?scope=user:email\u0026client_id=${CLIENT_ID}` where `${CLIENT_ID}` is the client_id from our GitHub App.\n\nAfter approval the user will be redirect back to the redirect URL you specified with a new URL parameter `code` which is used in subsequent requests.\n\nLet's create a basic `index.html` file with a login link using our client id:\n```html\n\u003chtml\u003e\n  \u003chead\u003e\n    \u003cscript src=\"zip.js\"\u003e\u003c/script\u003e\n    \u003cscript src=\"main.js\"\u003e\u003c/script\u003e\n  \u003c/head\u003e\n  \u003cbody\u003e\n    \u003cp\u003e\n      Well, hello there!\n    \u003c/p\u003e\n    \u003cp\u003e\n      We're going to now talk to the GitHub API. Ready?\n      \u003ca href=\"https://github.com/login/oauth/authorize?scope=user:email\u0026client_id=Iv1.bc38b449a74116b3\"\u003eClick here\u003c/a\u003e to begin!\n    \u003c/p\u003e\n    \u003cp\u003e\n      If that link doesn't work, remember to provide your own \u003ca href=\"/apps/building-oauth-apps/authorizing-oauth-apps/\"\u003eClient ID\u003c/a\u003e!\n    \u003c/p\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nYou now need to parse the code and from the URL parameter.\n\nNow the next request to get our token requires the `CLIENT_SECRET` which can't be stored on the front end. This is where the GitHub Actions solution we takled about before comes in.\n\nLet's update our workflow file to include the login. We need a new input code and some logic to perform the login request. Then we simply print the token to the logs so we can grab it later.\n```yml\nname: Login\n\non:\n  workflow_dispatch:\n    inputs:\n      code:\n        description: 'Temporary GitHub code from App authorization'\n        required: true\n      uid:\n        description: 'Unique ID for the request'\n        required: true\n\njobs:\n  login:\n    runs-on: ubuntu-latest\n    outputs:\n      token: ${{ steps.login-script.outputs.result }}\n    steps:\n      - name: ${{ inputs.uid }}\n        uses: actions/github-script@v6\n        id: login-script\n        env:\n          code: ${{ inputs.code }}\n          client_id: ${{ secrets.CLIENT_ID }}\n          client_secret: ${{ secrets.CLIENT_SECRET }}\n        with:\n          script: |\n            const { code, client_id, client_secret } = process.env;\n            const response = await fetch(\"https://github.com/login/oauth/access_token\", {\n              method: \"POST\",\n              headers: {\n                \"content-type\": \"application/json\",\n                accept: \"application/json\",\n              },\n              body: JSON.stringify({\n                client_id,\n                client_secret,\n                code\n              }),\n            });\n            result = await response.json();\n            const token = result.access_token\n            if (!token) core.setFailed(result.error_description);\n            return {\n              token: btoa(token)\n            };\n      - name: Result\n        run: printf '${{ steps.login-script.outputs.result }}'\n```\n\nSo now we can call GET [`/repos/${OWNER}/${REPO}/actions/workflows/${WORKFLOW_ID}/dispatches`]() again but this time pass the installation code as the input `code`. The result of the workflow should contain a GitHub token with the requested permissions.\n\nThat's it! Now you have a token and can create whatever you'd like in GitHub Pages using the GitHub API.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faustenstone%2Fgithub-actions-oauth","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faustenstone%2Fgithub-actions-oauth","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faustenstone%2Fgithub-actions-oauth/lists"}