Skip to content

Commit

Permalink
feat: express example (#50)
Browse files Browse the repository at this point in the history
* feat: express example with local static files

* feat: install ts-node for dev; parse cookies correctly

* chore: fix lint issues

* chore: change port to something sensible

* chore: install ts-node-dev

* fix: send index.html for all routes

* refactor: use try-catch in initServer

* feat: add logout route

* feat: remove logout redirect

* feat: add ice cream to userinfo

* refactor: store state as search params

* feat: retrieve static files from Cloudfront

* refactor: remove unused env var from env example

* chore: change port again

* chore: add README for express example

* feat: auto open browser on server start

* chore: add more stuff to .gitignore

* chore: add more detail to README

* chore: fix numbering in example app README

* chore: delete fetching of static frontend

This commit deletes the fetching of the static frontend because
we want to shift to a model of hosting the frontend separately from
the backend. The reason for this is that having a backend server
+ separate frontend SPA is a more common mode of development and
deployment

* feat: update example to work with frontend SPA

This commit makes a few changes:
1. Install CORS
2. Configure CORS to work with the frontend SPA
3. Remove code that serves the static frontend

This is in accordance with what we discussed, since a backend +
frontend SPA development pattern is expected to be most common.

* chore: add files to ignore for eslint

* chore: update documentation

* feat: update sgid client version and update to use PKCE

* chore: update Express example readme

* chore: remove unnecessary eslintignore file

* chore: misc fixes to cleanup example code

* chore: destructure params for cleanliness

* chore: delete session on logout

* chore: remove unused packages

* chore: remove unimportant typescript rule

---------

Co-authored-by: Antariksh <[email protected]>
  • Loading branch information
kwajiehao and mantariksh authored May 23, 2023
1 parent ac94159 commit baf5260
Show file tree
Hide file tree
Showing 9 changed files with 6,614 additions and 0 deletions.
3 changes: 3 additions & 0 deletions examples/express/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SGID_CLIENT_ID="Your client ID"
SGID_CLIENT_SECRET="Your client secret"
SGID_PRIVATE_KEY="Your private key"
18 changes: 18 additions & 0 deletions examples/express/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: {
commonjs: true,
es2021: true,
node: true,
},
extends: ['standard-with-typescript', 'plugin:prettier/recommended'],
overrides: [],
parserOptions: {
ecmaVersion: 'latest',
project: 'examples/express/tsconfig.json',
},
rules: {
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-dynamic-delete': 'off',
},
}
3 changes: 3 additions & 0 deletions examples/express/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/*
.env
node_modules/
8 changes: 8 additions & 0 deletions examples/express/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false
}
}
}
62 changes: 62 additions & 0 deletions examples/express/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ExpressJS example sgID app

The example application code is in `index.ts`. You can copy this code to bootstrap your sgID client application, and run this app locally to understand how this SDK helps you interact with the sgID server.

## Running this example app locally

### Prerequisites

Register a new client at the [sgID developer portal](https://developer.id.gov.sg). For detailed instructions on how to register, check out our [documentation](https://docs.id.gov.sg/introduction/getting-started/register-your-application). Feel free to register a test client; there is no limit to the number of clients you can create.

When registering a client, be sure to register the callback URL to be `http://localhost:5001/api/callback`, since this example runs on port `5001` by default.

### Steps to run locally

1. Clone this repo.

```
git clone https://github.com/opengovsg/sgid-client.git
```

2. Go to this folder and copy the contents of `.env.example` into a new file called `.env`.

```
cd sgid-client/examples/express
cat .env.example > .env
```

3. Replace the values in `.env` with the credentials of your sgID client (see [Prerequisites](#prerequisites)).

4. Run the Express app:

```
npm install
npm start
```

This should start the server on port `5001` by default

5. Clone sgID's example frontend repo:

```
git clone https://github.com/opengovsg/sgid-demo-frontend-spa.git
```

6. Install and run the example frontend:

```
cd sgid-demo-frontend-spa
npm install
npm run dev
```

This should start the frontend on port `5173` by default. Visit `http://localhost:5173` to test the entire sgID flow!

## For contributors

To start the server in debug mode, run:

```
npm install
npm run dev
```
173 changes: 173 additions & 0 deletions examples/express/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import express, { Router } from 'express'
import cors from 'cors'
import SgidClient, { generatePkcePair } from '@opengovsg/sgid-client'
import * as dotenv from 'dotenv'
import crypto from 'crypto'
import cookieParser from 'cookie-parser'
import open from 'open'

dotenv.config()

const PORT = 5001
const redirectUri = String(
process.env.SGID_REDIRECT_URI ?? `http://localhost:${PORT}/api/callback`,
)
const frontendHost = String(
process.env.SGID_FRONTEND_HOST ?? 'http://localhost:5173',
)

const sgid = new SgidClient({
clientId: String(process.env.SGID_CLIENT_ID),
clientSecret: String(process.env.SGID_CLIENT_SECRET),
privateKey: String(process.env.SGID_PRIVATE_KEY),
redirectUri,
})

const app = express()

const apiRouter = Router()

const SESSION_COOKIE_NAME = 'exampleAppSession'
const SESSION_COOKIE_OPTIONS = {
httpOnly: true,
}

type SessionData = Record<
string,
| {
nonce?: string
// Store state as search params to easily stringify key-value pairs
state?: URLSearchParams
accessToken?: string
codeVerifier?: string
sub?: string
}
| undefined
>

/**
* In-memory store for session data.
* In a real application, this would be a database.
*/
const sessionData: SessionData = {}

app.use(
cors({
credentials: true,
origin: frontendHost,
}),
)

apiRouter.get('/auth-url', (req, res) => {
const iceCreamSelection = String(req.query.icecream)
const sessionId = crypto.randomUUID()
// Use search params to store state so other key-value pairs
// can be added easily
const state = new URLSearchParams({
icecream: iceCreamSelection,
})

// Generate a PKCE pair
const { codeChallenge, codeVerifier } = generatePkcePair()

const { url, nonce } = sgid.authorizationUrl({
// We pass the user's ice cream preference as the state,
// so after they log in, we can display it together with the
// other user info.
state: state.toString(),
codeChallenge,
// Scopes that all sgID relying parties can access by default
scope: ['openid', 'myinfo.name'],
})
sessionData[sessionId] = {
state,
nonce,
codeVerifier,
}
return res
.cookie(SESSION_COOKIE_NAME, sessionId, SESSION_COOKIE_OPTIONS)
.json({ url })
})

apiRouter.get('/callback', async (req, res): Promise<void> => {
const authCode = String(req.query.code)
const state = String(req.query.state)
const sessionId = String(req.cookies[SESSION_COOKIE_NAME])

const session = { ...sessionData[sessionId] }
// Validate that the state matches what we passed to sgID for this session
if (session?.state?.toString() !== state) {
res.redirect(`${frontendHost}/error`)
return
}

// Validate that the code verifier exists for this session
if (session?.codeVerifier === undefined) {
res.redirect(`${frontendHost}/error`)
return
}

// Exchange the authorization code and code verifier for the access token
const { codeVerifier, nonce } = session
const { accessToken, sub } = await sgid.callback({
code: authCode,
nonce,
codeVerifier,
})

session.accessToken = accessToken
session.sub = sub
sessionData[sessionId] = session

// Successful login, redirect to logged in state
res.redirect(`${frontendHost}/logged-in`)
})

apiRouter.get('/userinfo', async (req, res) => {
const sessionId = String(req.cookies[SESSION_COOKIE_NAME])

// Retrieve the access token and sub
const session = sessionData[sessionId]
const accessToken = session?.accessToken
const sub = session?.sub

// User is not authenticated
if (session === undefined || accessToken === undefined || sub === undefined) {
return res.sendStatus(401)
}
const userinfo = await sgid.userinfo({
accessToken,
sub,
})

// Add ice cream flavour to userinfo
userinfo.data.iceCream = session.state?.get('icecream') ?? 'None'
return res.json(userinfo)
})

apiRouter.get('/logout', async (req, res) => {
const sessionId = String(req.cookies[SESSION_COOKIE_NAME])
delete sessionData[sessionId]
return res
.clearCookie(SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS)
.sendStatus(200)
})

const initServer = async (): Promise<void> => {
try {
app.use(cookieParser())
app.use('/api', apiRouter)

app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`)
void open(`http://localhost:${PORT}`)
})
} catch (error) {
console.error(
'Something went wrong while starting the server. Please restart the server.',
)
console.error(error)
}
}

void initServer()
Loading

0 comments on commit baf5260

Please sign in to comment.