From 0f29b2977f1250f29bebf5dc97df4ec96248e25c Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Sun, 17 Jan 2021 19:30:40 -0800 Subject: [PATCH] feat: GET /me now returns a providers attribute listing the identity providers user has authenticated with --- server/src/http/get-auth-me/index.ts | 6 ++- .../get-auth-redirect-000provider/index.ts | 2 +- .../src/shared/architect/oauth/handlers/me.ts | 34 ++++++++++++++--- .../architect/oauth/handlers/redirect.spec.ts | 2 +- .../architect/oauth/repository/Repository.ts | 4 +- .../oauth/repository/TokenRepository.spec.ts | 21 ++++++++++- .../oauth/repository/TokenRepository.ts | 37 ++++++++++++++++++- 7 files changed, 92 insertions(+), 14 deletions(-) diff --git a/server/src/http/get-auth-me/index.ts b/server/src/http/get-auth-me/index.ts index 753f804..8250a0f 100644 --- a/server/src/http/get-auth-me/index.ts +++ b/server/src/http/get-auth-me/index.ts @@ -1,6 +1,10 @@ import * as arc from "@architect/functions" import userRepositoryFactory from "@architect/shared/architect/oauth/repository/UserRepository" +import tokenRepositoryFactory from "@architect/shared/architect/oauth/repository/TokenRepository" import meHandlerFactory from "@architect/shared/architect/oauth/handlers/me" -const handlerImp = meHandlerFactory(userRepositoryFactory()) +const handlerImp = meHandlerFactory( + userRepositoryFactory(), + tokenRepositoryFactory() +) export const handler = arc.http.async(handlerImp) diff --git a/server/src/http/get-auth-redirect-000provider/index.ts b/server/src/http/get-auth-redirect-000provider/index.ts index 69e56f3..bef5e34 100644 --- a/server/src/http/get-auth-redirect-000provider/index.ts +++ b/server/src/http/get-auth-redirect-000provider/index.ts @@ -1,6 +1,6 @@ import * as arc from "@architect/functions" import oAuthRedirectHandlerFactory from "@architect/shared/architect/oauth/handlers/redirect" -import { tokenRepositoryFactory } from "@architect/shared/architect/oauth/repository/TokenRepository" +import tokenRepositoryFactory from "@architect/shared/architect/oauth/repository/TokenRepository" import userRepositoryFactory from "@architect/shared/architect/oauth/repository/UserRepository" import { fetchJson } from "@architect/shared/fetch" diff --git a/server/src/shared/architect/oauth/handlers/me.ts b/server/src/shared/architect/oauth/handlers/me.ts index 6aee780..df7ea88 100644 --- a/server/src/shared/architect/oauth/handlers/me.ts +++ b/server/src/shared/architect/oauth/handlers/me.ts @@ -1,5 +1,7 @@ import { HttpHandler, HttpRequest, HttpResponse } from "@architect/functions" +import { map } from "irritable-iterable" import { readSessionID } from "../../middleware/session" +import { TokenRepository } from "../repository/TokenRepository" import { StoredUser, UserRepository } from "../repository/UserRepository" import * as STATUS from "./httpStatus" @@ -9,15 +11,17 @@ import * as STATUS from "./httpStatus" * @param req The incoming Architect/APIG/Lambda request. */ export default function meHandlerFactory( - userRepository: UserRepository + userRepository: UserRepository, + tokenRepository: TokenRepository ): HttpHandler { - async function handlerImp( - req: HttpRequest - ): Promise { + async function handlerImp(req: HttpRequest): Promise { const sessionID = readSessionID(req) if (!sessionID) { return { statusCode: STATUS.UNAUTHENTICATED, + json: { + error: "request not authenticated", + }, } } const user = await userRepository.get(sessionID) @@ -25,11 +29,12 @@ export default function meHandlerFactory( return { statusCode: STATUS.NOT_FOUND, json: { - error: "not found", + error: "user not found", }, } } - // we try to be compliant with the OIDC UserInfo Response: + + // we try to be compliant with the OIDC UserInfo Response: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse return { statusCode: STATUS.OK, json: { @@ -37,8 +42,25 @@ export default function meHandlerFactory( email: user.email, createdAt: user.createdAt, updatedAt: user.updatedAt, + ...(await getProviders(user)), }, } } return handlerImp + + async function getProviders( + user: StoredUser + ): Promise<{ providers: string[] }> { + try { + const tokens = await tokenRepository.listForUser(user.id) + const providers: string[] = map(tokens, (t) => t.provider).collect() + return { + providers, + } + } catch (err) { + // providers are non-essential so rather than fail, just log it and return empty providers + console.error(err) + return { providers: [] } + } + } } diff --git a/server/src/shared/architect/oauth/handlers/redirect.spec.ts b/server/src/shared/architect/oauth/handlers/redirect.spec.ts index 31f541b..1e961eb 100644 --- a/server/src/shared/architect/oauth/handlers/redirect.spec.ts +++ b/server/src/shared/architect/oauth/handlers/redirect.spec.ts @@ -6,7 +6,7 @@ import { readSessionID, writeSessionID, } from "../../middleware/session" -import { tokenRepositoryFactory } from "../repository/TokenRepository" +import tokenRepositoryFactory from "../repository/TokenRepository" import userRepositoryFactory from "../repository/UserRepository" import oAuthRedirectHandlerFactory from "./redirect" import * as jwt from "node-webtokens" diff --git a/server/src/shared/architect/oauth/repository/Repository.ts b/server/src/shared/architect/oauth/repository/Repository.ts index 813fc2b..f44a374 100644 --- a/server/src/shared/architect/oauth/repository/Repository.ts +++ b/server/src/shared/architect/oauth/repository/Repository.ts @@ -42,7 +42,7 @@ export default abstract class Repository { await this.ensureInitialized() try { const now = Date.now() - // NOTE: explicitly NOT modifying the passed-in user obj + // NOTE: explicitly NOT modifying the passed-in obj const storedItem: T = { ...proposedItem, createdAt: now, @@ -87,7 +87,7 @@ export default abstract class Repository { // TODO: need to fix this. See Alert Genie for some examples of doing this more cleanly with an Iterable. assert( !scanned.LastEvaluatedKey, - "LastEvaluatedKey not empty. More users must exist and paging isn't implemented!" + "LastEvaluatedKey not empty. More items must exist and paging isn't implemented!" ) return scanned.Items as T[] } catch (err) { diff --git a/server/src/shared/architect/oauth/repository/TokenRepository.spec.ts b/server/src/shared/architect/oauth/repository/TokenRepository.spec.ts index 4d5e22b..b7f0271 100644 --- a/server/src/shared/architect/oauth/repository/TokenRepository.spec.ts +++ b/server/src/shared/architect/oauth/repository/TokenRepository.spec.ts @@ -1,9 +1,9 @@ +import { first } from "irritable-iterable" import { randomInt } from "../../../../../test/support" -import { +import tokenRepositoryFactory, { StoredToken, StoredTokenProposal, TokenRepository, - tokenRepositoryFactory, } from "./TokenRepository" let repo: TokenRepository @@ -64,6 +64,23 @@ describe("get", () => { }) }) +describe("listForUser", () => { + it("should return providers", async () => { + const proposed = randomToken() + await repo.upsert(proposed) + const tokens = await repo.listForUser(proposed.userID) + expect(tokens).toHaveLength(1) + expect(first(tokens)).toHaveProperty("provider", proposed.provider) + }) + + it("should be empty with no tokens", async () => { + const proposed = randomToken() + // don't add user: + const tokens = await repo.listForUser(proposed.userID) + expect(tokens).toHaveLength(0) + }) +}) + function expectStrictTokenProps(actual: StoredToken): void { const expectedProps: Record = { id: "string", diff --git a/server/src/shared/architect/oauth/repository/TokenRepository.ts b/server/src/shared/architect/oauth/repository/TokenRepository.ts index 1614da3..9dbd922 100644 --- a/server/src/shared/architect/oauth/repository/TokenRepository.ts +++ b/server/src/shared/architect/oauth/repository/TokenRepository.ts @@ -1,3 +1,4 @@ +import assert from "assert" import Repository from "./Repository" import { StoredItem } from "./StoredItem" @@ -5,10 +6,11 @@ export interface TokenRepository { upsert(token: StoredTokenProposal): Promise get(userID: string, provider: string): Promise list(): Promise> + listForUser(userID: string): Promise> delete(tokenID: string): Promise } -export function tokenRepositoryFactory(): TokenRepository { +export default function tokenRepositoryFactory(): TokenRepository { return new TokenRepositoryImpl() } @@ -49,6 +51,35 @@ class TokenRepositoryImpl return result.Item as StoredToken } + /** + * Lists all the tokens for the specified userID. + * @param userID + */ + public async listForUser(userID: string): Promise> { + if (!userID || typeof userID !== "string") { + throw new Error("userID argument must be provided and must be a string") + } + const result = await (await this.getDDB()) + .scan({ + TableName: await this.getTableName(), + FilterExpression: "begins_with(id, :id_prefix)", + ExpressionAttributeValues: { + ":id_prefix": this.idPrefix(userID), + }, + }) + .promise() + + if (!result.Items || result.Items.length === 0) { + return [] + } + // TODO: need to fix this. See Alert Genie for some examples of doing this more cleanly with an Iterable. + assert( + !result.LastEvaluatedKey, + "LastEvaluatedKey not empty. More items must exist and paging isn't implemented!" + ) + return result.Items as StoredToken[] + } + public async list(): Promise> { return this.listItems() } @@ -60,6 +91,10 @@ class TokenRepositoryImpl private idForToken(userID: string, provider: string): string { return `${this.tableNickname}:${userID}#${provider}` } + + private idPrefix(userID: string): string { + return `${this.tableNickname}:${userID}#` + } } /**