Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: express example #50

Merged
merged 31 commits into from
May 23, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f288c4b
feat: express example with local static files
mantariksh May 3, 2023
4da9c8d
feat: install ts-node for dev; parse cookies correctly
mantariksh May 4, 2023
e01e1f6
chore: fix lint issues
mantariksh May 5, 2023
548b862
chore: change port to something sensible
mantariksh May 5, 2023
7736b05
chore: install ts-node-dev
mantariksh May 5, 2023
a620203
fix: send index.html for all routes
mantariksh May 5, 2023
d2b4a89
refactor: use try-catch in initServer
mantariksh May 5, 2023
8f8d830
feat: add logout route
mantariksh May 5, 2023
e3a68f0
feat: remove logout redirect
mantariksh May 5, 2023
897e57f
feat: add ice cream to userinfo
mantariksh May 5, 2023
620a0f8
refactor: store state as search params
mantariksh May 5, 2023
af44a5c
feat: retrieve static files from Cloudfront
mantariksh May 5, 2023
f2d9bdc
refactor: remove unused env var from env example
mantariksh May 5, 2023
e030880
chore: change port again
mantariksh May 7, 2023
fbc0654
chore: add README for express example
mantariksh May 7, 2023
0752907
feat: auto open browser on server start
mantariksh May 7, 2023
dfc592b
chore: add more stuff to .gitignore
mantariksh May 8, 2023
bc86541
chore: add more detail to README
mantariksh May 8, 2023
d440639
chore: fix numbering in example app README
mantariksh May 8, 2023
c6d3c08
chore: delete fetching of static frontend
kwajiehao May 17, 2023
4e0c566
feat: update example to work with frontend SPA
kwajiehao May 17, 2023
fe0236b
chore: add files to ignore for eslint
kwajiehao May 17, 2023
306ac8d
chore: update documentation
kwajiehao May 17, 2023
9b6f35a
feat: update sgid client version and update to use PKCE
kwajiehao May 17, 2023
ca6c50a
chore: update Express example readme
kwajiehao May 18, 2023
bf5470a
chore: remove unnecessary eslintignore file
kwajiehao May 23, 2023
930c290
chore: misc fixes to cleanup example code
kwajiehao May 23, 2023
aca2c32
chore: destructure params for cleanliness
kwajiehao May 23, 2023
a0992a4
chore: delete session on logout
kwajiehao May 23, 2023
f17059f
chore: remove unused packages
kwajiehao May 23, 2023
beef837
chore: remove unimportant typescript rule
kwajiehao May 23, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"parserOptions": {
"ecmaVersion": 2018
},
"ignorePatterns": ["examples/**/*"],
"overrides": [
{
"files": ["*.ts"],
Expand Down
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"
17 changes: 17 additions & 0 deletions examples/express/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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',
},
}
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add instructions here on how to run the frontend?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated in ca6c50a

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:
kwajiehao marked this conversation as resolved.
Show resolved Hide resolved

```
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
```
174 changes: 174 additions & 0 deletions examples/express/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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])
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if we have to disable this rule, should we consider either turning off the rule or using javascript Maps instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Map is not fully supported by all browsers, I chose to turn off the rule instead. fixed in beef837

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(
kwajiehao marked this conversation as resolved.
Show resolved Hide resolved
'Something went wrong while starting the server. Please restart the server.',
)
console.error(error)
}
}

void initServer()
Loading