Skip to content

Commit 40f6afc

Browse files
hobbescodescoopbri
andauthored
OIDC Authentication (#28)
* build(deps): add hono oidc auth package * refactor(server): allow GET methods * build(deps): remove hono auth package, add generic auth envelop plugin package * feature(plugins): add useGenericAuth plugin WIP * chore: remove ts-ignore * refactor: add envs for AUTH_JWKS_URL, remove specific logs * refactor(plugins): update useGenericAuth to use jose for JWT handling * refactor(plugins): rename upsertedUser --> user * refactor(plugins): update naming convention, useGenericAuth --> useAuth * chore(plugins): update file name, useGenericAuth --> useAuth * refactor(plugins): update useAuth to dedup claims derived from payload * refactor(plugins): remove upsert from auth flow * chore(plugin): revert changes, continuw with upsert * docs: add JSDoc to createGraphQLContext function Co-authored-by: Brian Cooper <20056195+coopbri@users.noreply.github.com> * refactor(plugins): remove TODO, update catch block pattern Co-authored-by: Brian Cooper <20056195+coopbri@users.noreply.github.com> * docs: add JSDoc to resolveUser Co-authored-by: Brian Cooper <20056195+coopbri@users.noreply.github.com> --------- Co-authored-by: Brian Cooper <20056195+coopbri@users.noreply.github.com>
1 parent 038d97d commit 40f6afc

File tree

11 files changed

+116
-10
lines changed

11 files changed

+116
-10
lines changed

.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AUTH_JWKS_URL="https://hidra.omni.dev/realms/test/protocol/openid-connect/certs"

.env.production

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AUTH_JWKS_URL="https://hidra.omni.dev/realms/omni/protocol/openid-connect/certs"

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ node_modules/
33
.env
44
.env.*
55
!.env.*template
6+
!.env.development
7+
!.env.production
68

79
## Misc
810
.vscode

bun.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"workspaces": {
44
"": {
55
"dependencies": {
6+
"@envelop/generic-auth": "^8.0.1",
67
"@graphile/pg-aggregates": "^0.2.0-beta.7",
78
"@graphile/simplify-inflection": "^8.0.0-beta.5",
89
"dayjs": "^1.11.13",
@@ -12,6 +13,7 @@
1213
"graphql": "^16.9.0",
1314
"graphql-yoga": "^5.8.0",
1415
"hono": "^4.6.8",
16+
"jose": "^5.9.6",
1517
"pg": "^8.13.1",
1618
"postgraphile": "^5.0.0-beta.37",
1719
"postgraphile-plugin-connection-filter": "^3.0.0-beta.7",
@@ -80,6 +82,10 @@
8082

8183
"@envelop/core": ["@envelop/core@5.0.2", "", { "dependencies": { "@envelop/types": "5.0.0", "tslib": "^2.5.0" } }, "sha512-tVL6OrMe6UjqLosiE+EH9uxh2TQC0469GwF4tE014ugRaDDKKVWwFwZe0TBMlcyHKh5MD4ZxktWo/1hqUxIuhw=="],
8284

85+
"@envelop/extended-validation": ["@envelop/extended-validation@4.1.0", "", { "dependencies": { "@graphql-tools/utils": "^10.0.0", "tslib": "^2.5.0" }, "peerDependencies": { "@envelop/core": "^5.0.2", "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-S90LQanW+xg3Lkp2sNiHa2KJnXXpKLucKys05Wk5zpiV0vL0SDX+/cuV+tnDhShWJucunAGi34n8xFCXsUUOkA=="],
86+
87+
"@envelop/generic-auth": ["@envelop/generic-auth@8.0.1", "", { "dependencies": { "@envelop/extended-validation": "^4.1.0", "@graphql-tools/executor": "^1.3.6", "@graphql-tools/utils": "^10.5.1", "tslib": "^2.5.0" }, "peerDependencies": { "@envelop/core": "^5.0.2", "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-QznhYwj1Ri8Afkc6djtBTWI28xMdmKUxyqLc85XfpjxVnKrGcd8jdgV08SKiRQC29AH/rhSZprt6vXAXIsqRJA=="],
88+
8389
"@envelop/types": ["@envelop/types@5.0.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-IPjmgSc4KpQRlO4qbEDnBEixvtb06WDmjKfi/7fkZaryh5HuOmTtixe1EupQI5XfXO8joc3d27uUZ0QdC++euA=="],
8490

8591
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -308,6 +314,8 @@
308314

309315
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
310316

317+
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
318+
311319
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
312320

313321
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"typescript": "^5.6.3"
3333
},
3434
"dependencies": {
35+
"@envelop/generic-auth": "^8.0.1",
3536
"@graphile/pg-aggregates": "^0.2.0-beta.7",
3637
"@graphile/simplify-inflection": "^8.0.0-beta.5",
3738
"dayjs": "^1.11.13",
@@ -41,6 +42,7 @@
4142
"graphql": "^16.9.0",
4243
"graphql-yoga": "^5.8.0",
4344
"hono": "^4.6.8",
45+
"jose": "^5.9.6",
4446
"pg": "^8.13.1",
4547
"postgraphile": "^5.0.0-beta.37",
4648
"postgraphile-plugin-connection-filter": "^3.0.0-beta.7"

src/lib/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const {
77
// https://stackoverflow.com/a/68578294
88
HOST = "0.0.0.0",
99
DATABASE_URL,
10+
AUTH_JWKS_URL,
1011
} = process.env;
1112

1213
export const isDevEnv = NODE_ENV === "development";

src/lib/graphql/context.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createWithPgClient } from "@dataplan/pg/adaptors/pg";
2+
3+
import { dbPool } from "lib/db/db";
4+
import { pgPool } from "lib/db/pool";
5+
6+
import type { YogaInitialContext } from "graphql-yoga";
7+
import type { WithPgClient } from "postgraphile/@dataplan/pg";
8+
import type { NodePostgresPgClient } from "postgraphile/adaptors/pg";
9+
10+
const withPgClient = createWithPgClient({ pool: pgPool });
11+
12+
export interface GraphQLContext {
13+
db: typeof dbPool;
14+
request: Request;
15+
withPgClient: WithPgClient<NodePostgresPgClient>;
16+
}
17+
18+
/**
19+
* Create a GraphQL context.
20+
* @see https://graphql.org/learn/execution/#root-fields-and-resolvers
21+
*/
22+
export const createGraphQLContext = async ({
23+
request,
24+
}: YogaInitialContext): Promise<GraphQLContext> => ({
25+
db: dbPool,
26+
request,
27+
withPgClient,
28+
});

src/lib/graphql/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createGraphQLContext, type GraphQLContext } from "./context";

src/lib/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as useAuth } from "./useAuth";

src/lib/plugins/useAuth.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useGenericAuth } from "@envelop/generic-auth";
2+
import * as jose from "jose";
3+
4+
import { AUTH_JWKS_URL } from "lib/config/env";
5+
import { users } from "lib/drizzle/schema";
6+
7+
import type { ResolveUserFn } from "@envelop/generic-auth";
8+
import type { InsertUser, SelectUser } from "lib/drizzle/schema";
9+
import type { GraphQLContext } from "lib/graphql";
10+
11+
/**
12+
* Validate user session and resolve user if successful.
13+
* @see https://the-guild.dev/graphql/envelop/plugins/use-generic-auth#getting-started
14+
*/
15+
const resolveUser: ResolveUserFn<SelectUser, GraphQLContext> = async (
16+
context
17+
) => {
18+
try {
19+
const sessionToken = context.request.headers
20+
.get("authorization")
21+
?.split("Bearer ")[1];
22+
23+
if (!sessionToken) throw new Error("Invalid or missing session token");
24+
25+
const jwks = jose.createRemoteJWKSet(new URL(AUTH_JWKS_URL!));
26+
27+
const { payload } = await jose.jwtVerify(sessionToken, jwks);
28+
29+
if (!payload) throw new Error("Invalid or missing session token");
30+
31+
const insertedUser: InsertUser = {
32+
hidraId: payload.sub!,
33+
username: payload.preferred_username as string,
34+
firstName: payload.given_name as string,
35+
lastName: payload.family_name as string,
36+
};
37+
38+
const { hidraId, ...rest } = insertedUser;
39+
40+
const [user] = await context.db
41+
.insert(users)
42+
.values(insertedUser)
43+
.onConflictDoUpdate({
44+
target: users.hidraId,
45+
set: {
46+
...rest,
47+
updatedAt: new Date().toISOString(),
48+
},
49+
})
50+
.returning();
51+
52+
return user;
53+
} catch (err) {
54+
console.error(err);
55+
56+
return null;
57+
}
58+
};
59+
60+
const useAuth = () =>
61+
useGenericAuth({
62+
resolveUserFn: resolveUser,
63+
mode: "protect-all",
64+
});
65+
66+
export default useAuth;

0 commit comments

Comments
 (0)