-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
ac94159
commit baf5260
Showing
9 changed files
with
6,614 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dist/* | ||
.env | ||
node_modules/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"jsc": { | ||
"parser": { | ||
"syntax": "typescript", | ||
"tsx": false | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.