Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 8 additions & 8 deletions typescript/amp/src/AmpRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
const getRequest = <A, I>(
url: string,
responseSchema: Schema.Schema<A, I, never>,
auth?: Auth.AuthStorageSchema,
auth?: Model.CachedAuthInfo,
): Effect.Effect<Option.Option<A>, RegistryApiError, never> => {
const request = HttpClientRequest.get(url, { acceptJson: true })
const authenticatedRequest = auth
Expand Down Expand Up @@ -111,7 +111,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
const makeAuthenticatedRequest = <A, AE, I, IE>(
method: "POST" | "PUT",
url: string,
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
bodySchema: Schema.Schema<I, IE, never>,
body: I,
responseSchema: Schema.Schema<A, AE, never>,
Expand Down Expand Up @@ -180,7 +180,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
* @returns Option.some(dataset) if found, Option.none() if not found
*/
const getOwnedDataset = (
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
namespace: Model.DatasetNamespace,
name: Model.DatasetName,
): Effect.Effect<Option.Option<AmpRegistryDatasetDto>, RegistryApiError, never> =>
Expand All @@ -194,7 +194,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
* @returns Option of dataset from either endpoint
*/
const getDatasetWithFallback = (
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
namespace: Model.DatasetNamespace,
name: Model.DatasetName,
): Effect.Effect<Option.Option<AmpRegistryDatasetDto>, RegistryApiError, never> =>
Expand All @@ -214,7 +214,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
* @returns Created dataset
*/
const publishDataset = (
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
dto: AmpRegistryInsertDatasetDto,
): Effect.Effect<AmpRegistryDatasetDto, RegistryApiError, never> =>
makeAuthenticatedRequest(
Expand All @@ -235,7 +235,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
* @returns Created version
*/
const publishVersion = (
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
namespace: Model.DatasetNamespace,
name: Model.DatasetName,
dto: AmpRegistryInsertDatasetVersionDto,
Expand All @@ -258,7 +258,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
* @returns Updated dataset
*/
const updateDatasetMetadata = (
auth: Auth.AuthStorageSchema,
auth: Model.CachedAuthInfo,
namespace: Model.DatasetNamespace,
name: Model.DatasetName,
dto: AmpRegistryUpdateDatasetMetadataDto,
Expand Down Expand Up @@ -340,7 +340,7 @@ export class AmpRegistryService extends Effect.Service<AmpRegistryService>()("Am
*/
const publishFlow = Effect.fn("DatasetPublishFlow")(function*(
args: Readonly<{
auth: Auth.AuthStorageSchema
auth: Model.CachedAuthInfo
context: ManifestContext.DatasetContext
versionTag: Model.DatasetRevision
changelog?: string | undefined
Expand Down
48 changes: 16 additions & 32 deletions typescript/amp/src/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class GenerateAccessTokenRequest extends Schema.Class<GenerateAccessToken
export class GenerateAccessTokenResponse extends Schema.Class<GenerateAccessTokenResponse>(
"Amp/models/auth/GenerateAccessTokenResponse",
)({
token: Schema.NonEmptyTrimmedString,
token: Schema.Redacted(Schema.NonEmptyTrimmedString),
token_type: Schema.Literal("Bearer"),
exp: Schema.Int.pipe(Schema.positive()),
sub: Schema.NonEmptyTrimmedString,
Expand All @@ -60,17 +60,13 @@ export class GenerateAccessTokenError extends Data.TaggedError("Amp/errors/auth/
// Refresh Tokens
// =============================================================================

const AuthUserId = Schema.NonEmptyTrimmedString.pipe(
Schema.pattern(/^(c[a-z0-9]{24}|did:privy:c[a-z0-9]{24})$/),
)

export class RefreshTokenRequest extends Schema.Class<RefreshTokenRequest>(
"Amp/models/auth/RefreshTokenRequest",
)({
refresh_token: Model.RefreshToken,
user_id: AuthUserId,
refresh_token: Schema.Redacted(Model.RefreshToken),
user_id: Model.AuthenticatedUserId,
}) {
static fromCache(cache: AuthStorageSchema) {
static fromCache(cache: Model.CachedAuthInfo) {
return RefreshTokenRequest.make({
user_id: cache.userId,
refresh_token: cache.refreshToken,
Expand All @@ -91,7 +87,7 @@ export class RefreshTokenResponse extends Schema.Class<RefreshTokenResponse>(
description: "Seconds from receipt of when the token expires (def is 1hr)",
}),
user: Schema.Struct({
id: AuthUserId,
id: Model.AuthenticatedUserId,
accounts: Schema.Array(Schema.Union(Schema.NonEmptyTrimmedString, Model.Address)).annotations({
description: "List of accounts (connected wallets, etc) belonging to the user",
examples: [["cmfd6bf6u006vjx0b7xb2eybx", "0x5c8fA0bDf68C915a88cD68291fC7CF011C126C29"]],
Expand All @@ -101,18 +97,6 @@ export class RefreshTokenResponse extends Schema.Class<RefreshTokenResponse>(
}),
}) {}

// =============================================================================
// Token Cache
// =============================================================================

export class AuthStorageSchema extends Schema.Class<AuthStorageSchema>("Amp/models/auth/AuthStorageSchema")({
accessToken: Model.AccessToken,
refreshToken: Model.RefreshToken,
userId: AuthUserId,
accounts: Schema.Array(Schema.Union(Schema.NonEmptyTrimmedString, Model.Address)).pipe(Schema.optional),
expiry: Schema.Int.pipe(Schema.positive(), Schema.optional),
}) {}

// =============================================================================
// Errors
// =============================================================================
Expand All @@ -138,11 +122,11 @@ export class VerifySignedAccessTokenError extends Data.TaggedError("Amp/errors/a
readonly cause: unknown
}> {}

export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService", {
export class Auth extends Effect.Service<Auth>()("Amp/Auth", {
dependencies: [LocalCache, FetchHttpClient.layer],
effect: Effect.gen(function*() {
const store = yield* KeyValueStore.KeyValueStore
const kvs = store.forSchema(AuthStorageSchema)
const kvs = store.forSchema(Model.CachedAuthInfo)

// Setup the `${AUTH_PLATFORM_URL}/v1/auth` base URL for the HTTP client
const v1AuthUrl = new URL("api/v1/auth", AUTH_PLATFORM_URL)
Expand All @@ -157,7 +141,7 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"
// ------------------------------------------------------------------------

const generateAccessToken = Effect.fn("AuthService.generateAccessToken")(function*(args: {
readonly storedAuth: AuthStorageSchema
readonly cache: Model.CachedAuthInfo
readonly exp: Model.GenrateTokenDuration | undefined
readonly audience: ReadonlyArray<string> | null | undefined
}) {
Expand All @@ -169,7 +153,7 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"
audience: args.audience ?? undefined,
})),
acceptJson: true,
}).pipe(HttpClientRequest.bearerToken(args.storedAuth.accessToken))
}).pipe(HttpClientRequest.bearerToken(args.cache.accessToken))

return yield* httpClient.execute(request).pipe(
Effect.flatMap(HttpClientResponse.matchStatus({
Expand Down Expand Up @@ -211,7 +195,7 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"
)

const refreshAccessToken = Effect.fn("AuthService.refreshAccessToken")(function*(
cache: AuthStorageSchema,
cache: Model.CachedAuthInfo,
) {
const request = HttpClientRequest.post("/refresh", {
// Unsafely creating the JSON body is acceptable here as the `user_id`
Expand Down Expand Up @@ -259,12 +243,12 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"

const expiry = DateTime.toEpochMillis(DateTime.add(now, { seconds: response.expires_in }))
const accessToken = Model.AccessToken.make(response.token)
const refreshToken = Model.RefreshToken.make(response.refresh_token ?? cache.refreshToken)
const refreshedAuth = AuthStorageSchema.make({
const refreshToken = Model.RefreshToken.make(response.refresh_token ?? Redacted.value(cache.refreshToken))
const refreshedAuth = Model.CachedAuthInfo.make({
userId: response.user.id,
accounts: response.user.accounts,
accessToken,
refreshToken,
accessToken: Redacted.make(accessToken),
refreshToken: Redacted.make(refreshToken),
expiry,
})

Expand Down Expand Up @@ -315,7 +299,7 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"
return cache
}, Effect.option)

const setCache = Effect.fn("AuthService.setToken")(function*(cache: AuthStorageSchema) {
const setCache = Effect.fn("AuthService.setToken")(function*(cache: Model.CachedAuthInfo) {
yield* kvs.set(AUTH_TOKEN_CACHE_KEY, cache)
})

Expand All @@ -337,4 +321,4 @@ export class AuthService extends Effect.Service<AuthService>()("Amp/AuthService"
}),
}) {}

export const layer = AuthService.Default
export const layer = Auth.Default
20 changes: 18 additions & 2 deletions typescript/amp/src/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,29 @@ import { isAddress } from "viem"

export const Address = Schema.NonEmptyTrimmedString.pipe(Schema.filter((val) => isAddress(val)))

export const AuthenticatedUserId = Schema.NonEmptyTrimmedString.pipe(
Schema.pattern(/^(c[a-z0-9]{24}|did:privy:c[a-z0-9]{24})$/),
).annotations({ identifier: "AuthenticatedUserId" })
export type AuthenticatedUserId = typeof AuthenticatedUserId.Type

export const AccessToken = Schema.NonEmptyTrimmedString.pipe(
Schema.brand("AccessToken"),
)
).annotations({ identifier: "AccessToken" })
export type AccessToken = typeof AccessToken.Type

export const RefreshToken = Schema.NonEmptyTrimmedString.pipe(
Schema.brand("RefreshToken"),
)
).annotations({ identifier: "RefreshToken" })
export type RefreshToken = typeof RefreshToken.Type

export const CachedAuthInfo = Schema.Struct({
accessToken: Schema.Redacted(AccessToken),
refreshToken: Schema.Redacted(RefreshToken),
userId: AuthenticatedUserId,
accounts: Schema.optional(Schema.Array(Schema.Union(Schema.NonEmptyTrimmedString, Address))),
expiry: Schema.Int.pipe(Schema.positive(), Schema.optional),
}).annotations({ identifier: "CachedAuthInfo" })
export type CachedAuthInfo = typeof CachedAuthInfo.Type

export const Network = Schema.Lowercase.pipe(
Schema.annotations({
Expand Down
34 changes: 23 additions & 11 deletions typescript/amp/src/api/Admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import type * as HttpClientError from "@effect/platform/HttpClientError"
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import { constant } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Schema from "effect/Schema"
import * as Auth from "../Auth.ts"
import * as Model from "../Model.ts"
import * as Error from "./Error.ts"

Expand Down Expand Up @@ -487,7 +490,7 @@ export class Admin extends Context.Tag("Amp/Admin")<Admin, {
* @param url - The url of the admin api service.
* @returns An admin api service instance.
*/
export const make = Effect.fn(function*(url: string, options?: {
export const make = Effect.fn(function*(url: string | URL, options?: {
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined
readonly transformResponse?:
| ((effect: Effect.Effect<unknown, unknown>) => Effect.Effect<unknown, unknown>)
Expand Down Expand Up @@ -672,17 +675,26 @@ export const make = Effect.fn(function*(url: string, options?: {
* Creates a layer for the admin api service.
*
* @param url - The url of the admin api service.
* @param token - The bearer token.
* @returns A layer for the admin api service.
*/
export const layer = (url: string, token?: string) =>
Effect.gen(function*() {
const api = yield* make(url, {
transformClient: token === undefined ? undefined : (client) =>
client.pipe(
HttpClient.mapRequestInput(HttpClientRequest.setHeader("Authorization", `Bearer ${token}`)),
export const layer = (url: string | URL) =>
Layer.effect(Admin)(
Effect.gen(function*() {
const auth = yield* Effect.serviceOption(Auth.Auth)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@cmwhited - FYI this is what I was referring to by the "optional service pattern"


const transformClient = auth.pipe(
Option.map((auth) =>
HttpClient.mapRequestEffect(Effect.fnUntraced(function*(request) {
const cache = yield* auth.getCache()
return Option.match(cache, {
onSome: (cache) => HttpClientRequest.bearerToken(request, cache.accessToken),
onNone: constant(request),
})
}))
),
})
Option.getOrUndefined,
)

return api
}).pipe(Layer.effect(Admin), Layer.provide(FetchHttpClient.layer))
return yield* make(url, { transformClient })
}),
).pipe(Layer.provide(FetchHttpClient.layer))
13 changes: 8 additions & 5 deletions typescript/amp/src/cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Fiber from "effect/Fiber"
import * as Fn from "effect/Function"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Redacted from "effect/Redacted"
import * as Schedule from "effect/Schedule"
import * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
Expand Down Expand Up @@ -130,7 +131,7 @@ const DeviceTokenPollingResponse = Schema.Union(
)

const checkAlreadyAuthenticated = Effect.gen(function*() {
const auth = yield* Auth.AuthService
const auth = yield* Auth.Auth

const authResult = yield* auth.getCache()
return Option.isSome(authResult)
Expand Down Expand Up @@ -331,7 +332,7 @@ const authenticate = Effect.fn("PerformCliAuthentication")(function*(alreadyAuth
return yield* Effect.logInfo("amp cli already authenticated!")
}

const auth = yield* Auth.AuthService
const auth = yield* Auth.Auth

// Step 1: Request device authorization from the backend
const { codeVerifier, deviceAuth } = yield* requestDeviceAuthorization()
Expand All @@ -350,9 +351,11 @@ const authenticate = Effect.fn("PerformCliAuthentication")(function*(alreadyAuth
// Step 4: Store the tokens
const now = yield* DateTime.now
const expiry = Fn.pipe(now, DateTime.add({ seconds: tokenResponse.expires_in }), DateTime.toEpochMillis)
yield* auth.setCache(Auth.AuthStorageSchema.make({
accessToken: Model.AccessToken.make(tokenResponse.access_token),
refreshToken: Model.RefreshToken.make(tokenResponse.refresh_token),
const accessToken = Model.AccessToken.make(tokenResponse.access_token)
const refreshToken = Model.RefreshToken.make(tokenResponse.refresh_token)
yield* auth.setCache(Model.CachedAuthInfo.make({
accessToken: Redacted.make(accessToken),
refreshToken: Redacted.make(refreshToken),
userId: tokenResponse.user_id,
accounts: tokenResponse.user_accounts,
expiry,
Expand Down
2 changes: 1 addition & 1 deletion typescript/amp/src/cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const confirm = Prompt.confirm({

export const logout = Command.prompt("logout", Prompt.all([confirm]), ([confirm]) =>
Effect.gen(function*() {
const auth = yield* Auth.AuthService
const auth = yield* Auth.Auth

if (!confirm) {
return yield* Console.log("Exiting...")
Expand Down
27 changes: 11 additions & 16 deletions typescript/amp/src/cli/commands/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const token = Command.make("token", {
),
Command.withHandler(({ args }) =>
Effect.gen(function*() {
const auth = yield* Auth.AuthService
const auth = yield* Auth.Auth

const maybeAuthStorage = yield* auth.getCache()
if (Option.isNone(maybeAuthStorage)) {
Expand All @@ -42,7 +42,7 @@ export const token = Command.make("token", {
const authStorage = maybeAuthStorage.value

const response = yield* auth.generateAccessToken({
storedAuth: authStorage,
cache: authStorage,
exp: args.duration,
audience: Option.getOrElse(args.audience, () => undefined),
}).pipe(
Expand All @@ -60,19 +60,14 @@ export const token = Command.make("token", {
)

// verify the auth token against the JWKS
yield* auth
.verifySignedAccessToken(
Redacted.make(response.token),
response.iss,
)
.pipe(
Effect.catchTag("Amp/errors/auth/VerifySignedAccessTokenError", (error) =>
Console.error(`Failed to verify the signed token. JWT incorrectly formed: ${error.message}`).pipe(
Effect.flatMap(() =>
ExitCode.NonZero
),
)),
)
yield* auth.verifySignedAccessToken(response.token, response.iss).pipe(
Effect.catchTag("Amp/errors/auth/VerifySignedAccessTokenError", (error) =>
Console.error(`Failed to verify the signed token. JWT incorrectly formed: ${error.message}`).pipe(
Effect.flatMap(() =>
ExitCode.NonZero
),
)),
)

const exp = response.exp
const expDateTime = DateTime.unsafeMake(exp * 1000)
Expand All @@ -83,7 +78,7 @@ export const token = Command.make("token", {
yield* Console.log(
"Use this as a Authorization Bearer token in requests to query a published Amp Dataset to the Gateway",
)
yield* Console.log(` token:`, response.token)
yield* Console.log(` token:`, Redacted.value(response.token))
yield* Console.log(` exp:`, formatted)
return yield* ExitCode.Zero
})
Expand Down
Loading